@coreplane/switchboard 0.0.0 → 1.18.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/LICENSE +201 -0
- package/README.md +18 -1
- package/dist/assets/.dockerignore +27 -0
- package/dist/assets/.env.example +33 -0
- package/dist/assets/Dockerfile +111 -0
- package/dist/assets/config/config.example.yaml +359 -0
- package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
- package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
- package/dist/assets/deploy/bin/cf-logs +32 -0
- package/dist/assets/deploy/cloudflare/package.json +29 -0
- package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
- package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
- package/dist/assets/deploy/cloudflare/worker.ts +382 -0
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
- package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
- package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
- package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
- package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
- package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
- package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
- package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
- package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
- package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
- package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
- package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
- package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
- package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
- package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
- package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/profile.example.json +13 -0
- package/dist/assets/deploy/secrets.manifest.json +108 -0
- package/dist/assets/docker-entrypoint.sh +15 -0
- package/dist/assets/package-lock.json +18407 -0
- package/dist/assets/package.json +104 -0
- package/dist/assets/project.json +219 -0
- package/dist/assets/source.json +5 -0
- package/dist/assets/src/core/authz/actor.ts +100 -0
- package/dist/assets/src/core/authz/authorize.ts +169 -0
- package/dist/assets/src/core/authz/grants.ts +347 -0
- package/dist/assets/src/core/authz/policy.ts +281 -0
- package/dist/assets/src/core/authz/resource.ts +147 -0
- package/dist/assets/src/core/authz/types.ts +164 -0
- package/dist/assets/src/core/drain.ts +54 -0
- package/dist/assets/src/core/ingressTokens.ts +64 -0
- package/dist/assets/src/core/memory/engine.ts +115 -0
- package/dist/assets/src/core/memory/scorer.ts +147 -0
- package/dist/assets/src/core/memory/types.ts +120 -0
- package/dist/assets/src/core/normalizeSpans.ts +299 -0
- package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
- package/dist/assets/src/core/redact.ts +113 -0
- package/dist/assets/src/core/runEvents.ts +537 -0
- package/dist/assets/src/core/runFriction.ts +665 -0
- package/dist/assets/src/core/runLedger/decisions.ts +126 -0
- package/dist/assets/src/core/runLedger/types.ts +177 -0
- package/dist/assets/src/core/runRecord.ts +627 -0
- package/dist/assets/src/core/runShape.ts +61 -0
- package/dist/assets/src/core/schedules.ts +452 -0
- package/dist/assets/src/core/time/formatDuration.ts +61 -0
- package/dist/assets/src/core/trace/attrs.ts +203 -0
- package/dist/assets/src/core/trace/classify.ts +49 -0
- package/dist/assets/src/core/trace/clock.ts +6 -0
- package/dist/assets/src/core/trace/context.ts +9 -0
- package/dist/assets/src/core/trace/ids.ts +23 -0
- package/dist/assets/src/core/trace/partition.ts +235 -0
- package/dist/assets/src/core/trace/sinks.ts +68 -0
- package/dist/assets/src/core/trace/streamSpans.ts +163 -0
- package/dist/assets/src/core/trace/traceparent.ts +29 -0
- package/dist/assets/src/core/trace/tracer.ts +247 -0
- package/dist/assets/src/core/trace/types.ts +125 -0
- package/dist/assets/src/core/trace/workerTrace.ts +97 -0
- package/dist/assets/src/deploy/buildStamp.ts +93 -0
- package/dist/assets/src/deploy/liveGate.ts +203 -0
- package/dist/assets/src/deploy/profile.ts +162 -0
- package/dist/assets/src/deploy/restart.ts +393 -0
- package/dist/assets/src/effort.ts +17 -0
- package/dist/assets/src/execution/bashTimeout.ts +78 -0
- package/dist/assets/src/execution/bindingPurge.ts +43 -0
- package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
- package/dist/assets/src/execution/residentCleanliness.ts +95 -0
- package/dist/assets/src/execution/residentCredentials.ts +81 -0
- package/dist/assets/src/execution/residentDepCache.ts +321 -0
- package/dist/assets/src/execution/residentDepsStore.ts +326 -0
- package/dist/assets/src/execution/residentDetach.ts +48 -0
- package/dist/assets/src/execution/residentDisk.ts +107 -0
- package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
- package/dist/assets/src/execution/residentExecWrap.ts +100 -0
- package/dist/assets/src/execution/residentHead.ts +85 -0
- package/dist/assets/src/execution/residentReadonly.ts +72 -0
- package/dist/assets/src/execution/residentRefresh.ts +429 -0
- package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
- package/dist/assets/src/execution/residentState.ts +47 -0
- package/dist/assets/src/execution/residentStepReport.ts +98 -0
- package/dist/assets/src/execution/residentStepTrace.ts +97 -0
- package/dist/assets/src/execution/residentSteps.ts +99 -0
- package/dist/assets/src/execution/residentText.ts +83 -0
- package/dist/assets/src/execution/residentTrace.ts +119 -0
- package/dist/assets/src/execution/sandboxEnv.ts +42 -0
- package/dist/assets/src/execution/sandboxErrors.ts +159 -0
- package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
- package/dist/assets/src/execution/shellQuote.ts +8 -0
- package/dist/assets/src/mcp/registry.ts +242 -0
- package/dist/assets/src/providers/types.ts +152 -0
- package/dist/assets/web/dist/.vite/manifest.json +176 -0
- package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
- package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
- package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
- package/dist/assets/web/dist/assets/ResidentDetailPage-D3shEnzl.js +1 -0
- package/dist/assets/web/dist/assets/ResidentsIndexPage-DWIubQ05.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-BMjuE-oX.js +126 -0
- package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-C3_jYIo0.js +1 -0
- package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
- package/dist/assets/web/dist/assets/ScheduledPage-g1W58mtN.js +1 -0
- package/dist/assets/web/dist/assets/StatusDot-DcPRw3zu.js +1 -0
- package/dist/assets/web/dist/assets/Tooltip-DJUkMYjo.js +1 -0
- package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
- package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
- package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
- package/dist/assets/web/dist/assets/main-CyM5f4JC.js +28 -0
- package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
- package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
- package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
- package/dist/cli.js +34494 -0
- package/package.json +43 -10
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { MemoryRecord } from "./types.js";
|
|
2
|
+
|
|
3
|
+
// Pure retrieval scoring, budgeting, and rendering. No I/O, no clock of its own
|
|
4
|
+
// (the caller passes `now`) — so every branch is unit-testable in isolation.
|
|
5
|
+
// MVP score = α·keywordMatch + β·recency (Generative-Agents-style, minus the
|
|
6
|
+
// embedding-relevance term): keyword+recency is a legitimate MVP; vectors are an
|
|
7
|
+
// upgrade behind the unchanged seam ("do you even need vectors at MVP?").
|
|
8
|
+
|
|
9
|
+
/** Relative weights of the two scoring terms. */
|
|
10
|
+
export interface ScoreWeights {
|
|
11
|
+
keyword: number;
|
|
12
|
+
recency: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** α (keyword) dominates; β (recency) breaks ties and sinks stale records. */
|
|
16
|
+
export const DEFAULT_WEIGHTS: ScoreWeights = { keyword: 0.7, recency: 0.3 };
|
|
17
|
+
|
|
18
|
+
/** Exponential-decay time constant for the recency term (~1 week). A record
|
|
19
|
+
* one τ old scores 1/e on recency; the decay makes stale facts sink without a
|
|
20
|
+
* sweeper job (decay lives in the score). */
|
|
21
|
+
export const RECENCY_TAU_MS = 7 * 24 * 60 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/** Default read budget: at most 8 records / ~800 tokens injected, regardless of
|
|
24
|
+
* store size — context never bloats. */
|
|
25
|
+
export const DEFAULT_MEMORY_LIMIT = 8;
|
|
26
|
+
export const DEFAULT_MEMORY_TOKENS = 800;
|
|
27
|
+
|
|
28
|
+
export interface MemoryBudget {
|
|
29
|
+
maxRecords: number;
|
|
30
|
+
maxTokens: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Split text into lowercased alphanumeric tokens for keyword matching. */
|
|
34
|
+
export function tokenize(text: string): string[] {
|
|
35
|
+
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Fraction of the query's word-tokens that appear as a word in the record (its
|
|
39
|
+
* keyword set or its text). 0 when the query has no tokens. Whole-token
|
|
40
|
+
* matching, not raw substring: substring matching lets a one-letter query token
|
|
41
|
+
* ("a") match inside an unrelated word ("comm-a-nd") and pollute retrieval. */
|
|
42
|
+
export function keywordMatch(record: MemoryRecord, query: string | readonly string[]): number {
|
|
43
|
+
const queryTokens = typeof query === "string" ? tokenize(query) : query;
|
|
44
|
+
if (queryTokens.length === 0) return 0;
|
|
45
|
+
const recordTokens = new Set([...tokenize(record.keywords.join(" ")), ...tokenize(record.text)]);
|
|
46
|
+
let hits = 0;
|
|
47
|
+
for (const token of queryTokens) {
|
|
48
|
+
if (recordTokens.has(token)) hits++;
|
|
49
|
+
}
|
|
50
|
+
return hits / queryTokens.length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Exponential recency decay over `lastUsedAt ?? createdAt`, in (0, 1]. */
|
|
54
|
+
export function recencyScore(record: MemoryRecord, now: number, tau: number = RECENCY_TAU_MS): number {
|
|
55
|
+
const ts = record.lastUsedAt ?? record.createdAt;
|
|
56
|
+
const age = Math.max(0, now - ts);
|
|
57
|
+
return Math.exp(-age / tau);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** score = α·keywordMatch + β·recency. Pure; the caller supplies `now`. */
|
|
61
|
+
export function scoreRecord(
|
|
62
|
+
record: MemoryRecord,
|
|
63
|
+
query: string | readonly string[],
|
|
64
|
+
now: number,
|
|
65
|
+
weights: ScoreWeights = DEFAULT_WEIGHTS,
|
|
66
|
+
tau: number = RECENCY_TAU_MS,
|
|
67
|
+
): number {
|
|
68
|
+
return weights.keyword * keywordMatch(record, query) + weights.recency * recencyScore(record, now, tau);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Rough token estimate (~4 chars/token) for the budget cap. */
|
|
72
|
+
export function estimateTokens(text: string): number {
|
|
73
|
+
return Math.ceil(text.length / 4);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Apply the hard budget: at most `maxRecords`, and stop before the running
|
|
77
|
+
* token estimate would exceed `maxTokens` (the first record is always kept so a
|
|
78
|
+
* single large record is never silently dropped). Input is assumed already
|
|
79
|
+
* ranked; order is preserved. Each record is costed at its *rendered* bullet
|
|
80
|
+
* (`renderMemoryBullet`) — sanitized/escaped text plus the `- … (source: …)`
|
|
81
|
+
* boilerplate — not the raw `record.text`. `sanitizeMemoryField` can lengthen
|
|
82
|
+
* text (`<`→`<` is +3 chars each), so budgeting the raw text would
|
|
83
|
+
* under-count and let the rendered block exceed `maxTokens`; costing the
|
|
84
|
+
* rendered bullet keeps the estimate an upper bound on the injected records. */
|
|
85
|
+
export function applyBudget(records: MemoryRecord[], budget: MemoryBudget): MemoryRecord[] {
|
|
86
|
+
const out: MemoryRecord[] = [];
|
|
87
|
+
let tokens = 0;
|
|
88
|
+
for (const record of records) {
|
|
89
|
+
if (out.length >= budget.maxRecords) break;
|
|
90
|
+
const cost = estimateTokens(renderMemoryBullet(record));
|
|
91
|
+
if (out.length > 0 && tokens + cost > budget.maxTokens) break;
|
|
92
|
+
out.push(record);
|
|
93
|
+
tokens += cost;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The exact prefix line of the injected context block. */
|
|
99
|
+
export function memoryBlockPrefix(resource: string): string {
|
|
100
|
+
return `Background memory for ${resource} (may be outdated — verify before acting):`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Neutralize a memory record field before it is spliced into the system
|
|
104
|
+
* prompt: strip control characters and any newline/line-separator (so a
|
|
105
|
+
* record can never inject extra lines or fake role turns), collapse the
|
|
106
|
+
* resulting whitespace, then escape `&`\u2192`&` FIRST, then `<`\u2192`<` and
|
|
107
|
+
* `>`\u2192`>`. Escaping `&` before the angle brackets keeps the escaping
|
|
108
|
+
* complete and unambiguous: a record's literal `<` becomes `&lt;`, so it
|
|
109
|
+
* no longer renders identically to an escaped `<`. Escaping angle brackets means
|
|
110
|
+
* a record field can contain no literal `<` or `>`, so it can never forge the
|
|
111
|
+
* `<background_memory>`/`</background_memory>` fence delimiter \u2014 or any other
|
|
112
|
+
* tag \u2014 inline within a bullet. This is the anti-poisoning containment for the
|
|
113
|
+
* memory block (see docs/reference/specs/memory.md). */
|
|
114
|
+
export function sanitizeMemoryField(s: string): string {
|
|
115
|
+
return (
|
|
116
|
+
s
|
|
117
|
+
// eslint-disable-next-line no-control-regex -- control characters are exactly what this strips
|
|
118
|
+
.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/g, " ")
|
|
119
|
+
.replace(/\s+/g, " ")
|
|
120
|
+
.trim()
|
|
121
|
+
.replace(/&/g, "&")
|
|
122
|
+
.replace(/</g, "<")
|
|
123
|
+
.replace(/>/g, ">")
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The exact rendered bullet for one record — the single source of truth shared
|
|
128
|
+
* by `renderMemoryBlock` (what is injected) and `applyBudget` (what is costed),
|
|
129
|
+
* so the budget estimate can never diverge from what is actually rendered. Both
|
|
130
|
+
* record fields pass through `sanitizeMemoryField` (control/newline strip +
|
|
131
|
+
* `&`/`<`/`>` escape). */
|
|
132
|
+
function renderMemoryBullet(record: MemoryRecord): string {
|
|
133
|
+
return `- ${sanitizeMemoryField(record.text)} (source: ${sanitizeMemoryField(record.sourceThreadKey)})`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Render the dedicated advisory context block: the exact prefix line, then one
|
|
137
|
+
* bullet per record with its provenance, fenced by an unambiguous
|
|
138
|
+
* `<background_memory>` delimiter. Framed as advisory ("may be outdated —
|
|
139
|
+
* verify") — memory is context, never instructions. The fence lines are
|
|
140
|
+
* written here literally; the record-derived fields pass through
|
|
141
|
+
* `sanitizeMemoryField` (control/newline strip + `&`/`<`/`>` escape), so a
|
|
142
|
+
* record can neither inject extra lines / fake role turns nor forge the fence
|
|
143
|
+
* delimiter — or any tag — from within a bullet (anti-poisoning). */
|
|
144
|
+
export function renderMemoryBlock(resource: string, records: MemoryRecord[]): string {
|
|
145
|
+
const bullets = records.map(renderMemoryBullet);
|
|
146
|
+
return [memoryBlockPrefix(resource), "<background_memory>", ...bullets, "</background_memory>"].join("\n");
|
|
147
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Cross-session self-learning memory. The seam: distilled records scoped to a
|
|
2
|
+
// resource, retrieved by keyword+recency and injected as a dedicated advisory
|
|
3
|
+
// context block before the model turn. The READ path sits behind a flag
|
|
4
|
+
// (default off → zero behavior change); the write/reflection path and the
|
|
5
|
+
// durable Worker store hang off the same interface. See docs/reference/specs/memory.md and
|
|
6
|
+
// docs/decisions/0017-memory-off-by-default.md.
|
|
7
|
+
|
|
8
|
+
/** A distilled, self-contained memory record. Never a raw transcript — those
|
|
9
|
+
* live in thread history; duplicating them defeats the token win. Every record
|
|
10
|
+
* keeps provenance (`sourceThreadKey`/`sourceRunId`) so a distilled fact can be
|
|
11
|
+
* re-grounded in the run that produced it. */
|
|
12
|
+
import type { TraceOptions } from "../trace/types.js";
|
|
13
|
+
|
|
14
|
+
export interface MemoryRecord {
|
|
15
|
+
/** Internal id, namespaced per AGENTS.md invariant 4 (`mem:<scopeKey>:<n>`). */
|
|
16
|
+
id: string;
|
|
17
|
+
/** The resource this record belongs to: `org:<organization>` (the config's organization) or `user:slack:U…`. */
|
|
18
|
+
scopeKey: string;
|
|
19
|
+
/** Semantic fact vs. episodic thread summary. */
|
|
20
|
+
kind: "fact" | "summary";
|
|
21
|
+
/** Self-contained natural-language statement. */
|
|
22
|
+
text: string;
|
|
23
|
+
/** Lowercased keywords for keyword/FTS retrieval at MVP (no embeddings). */
|
|
24
|
+
keywords: string[];
|
|
25
|
+
/** Provenance: the thread this was distilled from (`slack:C…:<ts>`). */
|
|
26
|
+
sourceThreadKey: string;
|
|
27
|
+
/** Provenance: ties to the runRegistry / live-view page, when known. */
|
|
28
|
+
sourceRunId?: string;
|
|
29
|
+
createdAt: number;
|
|
30
|
+
/** Bumped on retrieval → drives the recency term and decay. */
|
|
31
|
+
lastUsedAt?: number;
|
|
32
|
+
useCount: number;
|
|
33
|
+
/** Extractor's importance/confidence (0–1); folded into scoring later. */
|
|
34
|
+
confidence?: number;
|
|
35
|
+
/** id this record replaces (conflict resolution / supersession). */
|
|
36
|
+
supersedes?: string;
|
|
37
|
+
/** `evicted`: dropped by the per-scope cap (least recently used);
|
|
38
|
+
* `superseded`: replaced by a newer record; `forgotten`: removed by a human
|
|
39
|
+
* via `memory forget`. Both are soft deletes — the row stays for
|
|
40
|
+
* provenance but is invisible to retrieval, list, and dedup. */
|
|
41
|
+
status: "active" | "superseded" | "forgotten" | "evicted";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the reflection extractor emits. The store assigns id/timestamps/useCount/
|
|
45
|
+
* status on write, so a candidate carries only the distilled content and its
|
|
46
|
+
* provenance. */
|
|
47
|
+
export interface MemoryCandidate {
|
|
48
|
+
kind: "fact" | "summary";
|
|
49
|
+
text: string;
|
|
50
|
+
keywords?: string[];
|
|
51
|
+
sourceThreadKey: string;
|
|
52
|
+
sourceRunId?: string;
|
|
53
|
+
confidence?: number;
|
|
54
|
+
supersedes?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A retrieval request: which resource, what to match, how many at most. */
|
|
58
|
+
export interface MemoryQuery {
|
|
59
|
+
scopeKey: string;
|
|
60
|
+
query: string;
|
|
61
|
+
limit: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The boundary the core sees (AGENTS.md invariant 2: the dispatcher depends on
|
|
65
|
+
* this interface, never a concrete store). Three implementations:
|
|
66
|
+
* `NullMemoryStore` (disabled default), `InMemoryMemoryStore` (tests/dev) and
|
|
67
|
+
* the durable `WorkerMemoryStore`. */
|
|
68
|
+
export interface MemoryStore {
|
|
69
|
+
/** Scope-partitioned retrieval, ranked by the pure scorer, oldest-irrelevant
|
|
70
|
+
* dropped. Returns [] when nothing matches. */
|
|
71
|
+
retrieve(q: MemoryQuery, trace?: TraceOptions): Promise<MemoryRecord[]>;
|
|
72
|
+
/** Persist distilled candidates. Dedup (identical normalized text → bump
|
|
73
|
+
* `useCount`) and supersede (`supersedes` id → old record soft-deleted) live
|
|
74
|
+
* inside the store. Driven by the post-run reflection pass (reflection.ts). */
|
|
75
|
+
write(scopeKey: string, records: MemoryCandidate[]): Promise<void>;
|
|
76
|
+
/** Human view: a scope's ACTIVE records, newest first, at most
|
|
77
|
+
* `limit`. Unlike `retrieve` this never bumps usage. */
|
|
78
|
+
/** `query`: when given, only records that a query token hits
|
|
79
|
+
* (whole-token, text or keywords) are listed — the filter narrows, it
|
|
80
|
+
* never ranks or bumps usage. */
|
|
81
|
+
list(scopeKey: string, limit: number, query?: string): Promise<MemoryRecord[]>;
|
|
82
|
+
/** Human control: soft-delete one ACTIVE record of this scope
|
|
83
|
+
* (`status: "forgotten"`, row kept for provenance). Resolves true when a
|
|
84
|
+
* record was forgotten, false when the id names nothing active in this
|
|
85
|
+
* scope — a foreign-scope id can never be forgotten through another scope. */
|
|
86
|
+
forget(scopeKey: string, id: string): Promise<boolean>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The resources memory is scoped to. `org` is the shared resource every
|
|
90
|
+
* request reads; `user` is the requesting person's own records,
|
|
91
|
+
* read and written only for that person; `repo` (the run's bound repository)
|
|
92
|
+
* and `channel` (the message's channel) are shared by everyone who runs there
|
|
93
|
+
* in the same place. Tighter scope prevents cross-context poisoning (mirrors
|
|
94
|
+
* Claude's compartmentalization). */
|
|
95
|
+
export type MemoryScope = "org" | "user" | "repo" | "channel";
|
|
96
|
+
|
|
97
|
+
/** The `memory` config section (all optional; default OFF). */
|
|
98
|
+
export interface MemoryConfig {
|
|
99
|
+
/** Master switch. Default false → `NullMemoryStore` → zero behavior change. */
|
|
100
|
+
enabled?: boolean;
|
|
101
|
+
/** Max records retrieved/injected per request. Default 8. */
|
|
102
|
+
limit?: number;
|
|
103
|
+
/** Hard token budget for the injected block. Default ~800. */
|
|
104
|
+
maxTokens?: number;
|
|
105
|
+
/** Per-scope cap on ACTIVE records. A write that would leave a scope
|
|
106
|
+
* over the cap evicts the least recently used records (soft delete, status
|
|
107
|
+
* `evicted`) down to it, inside the same write. Default 500. */
|
|
108
|
+
maxRecordsPerScope?: number;
|
|
109
|
+
/** `<provider>/<model>` ref for the post-run reflection (write path) — a cheap
|
|
110
|
+
* tier. Absent → the run's own resolved model. Never hardcoded (invariant 7). */
|
|
111
|
+
model?: string;
|
|
112
|
+
/** The durable store: the Memory Worker (deploy/cloudflare-memory/). Absent →
|
|
113
|
+
* an in-process store that a restart loses (dev only; startup warns). */
|
|
114
|
+
worker?: {
|
|
115
|
+
/** Base URL, e.g. https://switchboard-memory.example.com */
|
|
116
|
+
baseUrl: string;
|
|
117
|
+
/** Env var holding the bearer secret. Default MEMORY_TOKEN. */
|
|
118
|
+
tokenEnv?: string;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { isSpanRecord, type RunEvent, type SpanEndEvent, type SpanStartEvent } from "./runEvents.js";
|
|
2
|
+
import type { LossInterval } from "./trace/partition.js";
|
|
3
|
+
import type { SpanRecord } from "./trace/types.js";
|
|
4
|
+
import type { SpanAttrs } from "./trace/attrs.js";
|
|
5
|
+
|
|
6
|
+
// The one Adapter between a run stream and the span set the partition, the
|
|
7
|
+
// analyzer and the timeline read (docs/reference/specs/tracing.md). A stream is
|
|
8
|
+
// span schema (`SPAN_SCHEMA`): spans carry the timing. A content pair whose
|
|
9
|
+
// `tool.*` twin was dropped by the record budget still deserves a span, so
|
|
10
|
+
// `normalizeSpans` adds a synthesized span for every stamped, `callId`-keyed
|
|
11
|
+
// pair that lacks one and never touches a pair that has one — it is idempotent
|
|
12
|
+
// and the identity on a complete stream. A record below `SPAN_SCHEMA` is never
|
|
13
|
+
// normalized: its readers show no timing (docs/reference/migrations.md).
|
|
14
|
+
// `spansFromEvents` pairs the records into `SpanRecord`s and `lossesFromStream`
|
|
15
|
+
// derives the loss intervals from the `seq` gaps, the `spans_dropped` notes and
|
|
16
|
+
// the live transport's elided ranges.
|
|
17
|
+
|
|
18
|
+
/** The stream schema from which spans are the timing record. A record whose
|
|
19
|
+
* `schema` is absent or below it carries no timing. */
|
|
20
|
+
export const SPAN_SCHEMA = 2;
|
|
21
|
+
|
|
22
|
+
/** A synthesized span's id: `synth:<callId>` for a call whose id is a plain
|
|
23
|
+
* token, else `synth:<content index>`. */
|
|
24
|
+
function synthId(callId: string, index: number): string {
|
|
25
|
+
return /^[A-Za-z0-9_-]{1,64}$/.test(callId) ? `synth:${callId}` : `synth:${index}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface OpenAgent {
|
|
29
|
+
spanId: string;
|
|
30
|
+
startedAt: number;
|
|
31
|
+
endedAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The `run.agent` spans that survive on the stream, as intervals — a
|
|
35
|
+
* synthesized span's parent is the innermost one containing it. */
|
|
36
|
+
function agentSpans(events: readonly RunEvent[]): OpenAgent[] {
|
|
37
|
+
const out: OpenAgent[] = [];
|
|
38
|
+
for (const e of events) {
|
|
39
|
+
if (e.type === "span_end" && e.name === "run.agent") {
|
|
40
|
+
out.push({ spanId: e.spanId, startedAt: e.startedAt, endedAt: e.startedAt + e.durationMs });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parentFor(agents: readonly OpenAgent[], at: number): string | undefined {
|
|
47
|
+
let best: OpenAgent | undefined;
|
|
48
|
+
for (const a of agents) {
|
|
49
|
+
if (a.startedAt <= at && at <= a.endedAt && (!best || a.endedAt - a.startedAt < best.endedAt - best.startedAt))
|
|
50
|
+
best = a;
|
|
51
|
+
}
|
|
52
|
+
return best?.spanId;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Synthesize the `tool.*` spans a stream is missing: one per stamped
|
|
57
|
+
* `tool_call` with a `callId` and no twin, closed by the `tool_result` with the
|
|
58
|
+
* same `callId`. Returns a new array; the input is never mutated. The
|
|
59
|
+
* synthesized `span_start` is placed right before the call, the `span_end`
|
|
60
|
+
* right after the result, so a per-event fold sees them where the emitter
|
|
61
|
+
* would have put them. A call with no `callId` or no `at` gets no span: there
|
|
62
|
+
* is nothing to key or to time it by.
|
|
63
|
+
*/
|
|
64
|
+
export function normalizeSpans(events: readonly RunEvent[]): RunEvent[] {
|
|
65
|
+
const agents = agentSpans(events);
|
|
66
|
+
|
|
67
|
+
// What already has a twin.
|
|
68
|
+
const toolSpanCallIds = new Set<string>();
|
|
69
|
+
for (const e of events) {
|
|
70
|
+
if (!isSpanRecord(e)) continue;
|
|
71
|
+
const attrs = (e.attrs ?? {}) as Record<string, unknown>;
|
|
72
|
+
if (e.name.startsWith("tool.") && typeof attrs.callId === "string") toolSpanCallIds.add(attrs.callId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const out: RunEvent[] = [];
|
|
76
|
+
// A call's synthesized start, keyed by callId, until its result closes it.
|
|
77
|
+
const openCalls = new Map<string, { spanId: string; startedAt: number }>();
|
|
78
|
+
|
|
79
|
+
events.forEach((e, index) => {
|
|
80
|
+
switch (e.type) {
|
|
81
|
+
case "tool_call": {
|
|
82
|
+
const callId = e.callId;
|
|
83
|
+
if (callId === undefined || e.at === undefined || toolSpanCallIds.has(callId)) {
|
|
84
|
+
out.push(e);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const spanId = synthId(callId, index);
|
|
88
|
+
out.push(spanStart(spanId, `tool.${e.tool}`, e.at, parentFor(agents, e.at), { callId }), e);
|
|
89
|
+
openCalls.set(callId, { spanId, startedAt: e.at });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
case "tool_result": {
|
|
93
|
+
out.push(e);
|
|
94
|
+
if (e.callId === undefined) return;
|
|
95
|
+
const open = openCalls.get(e.callId);
|
|
96
|
+
if (!open) return; // a result whose call has a real twin (or no call at all): nothing to close
|
|
97
|
+
openCalls.delete(e.callId);
|
|
98
|
+
const endedAt = e.at ?? open.startedAt;
|
|
99
|
+
const attrs: Record<string, string | number | boolean> = { ok: e.ok, callId: e.callId };
|
|
100
|
+
if (e.exitCode !== undefined) attrs.exitCode = e.exitCode;
|
|
101
|
+
if (e.infra) attrs.infra = true;
|
|
102
|
+
out.push(
|
|
103
|
+
spanEnd(
|
|
104
|
+
open.spanId,
|
|
105
|
+
`tool.${e.tool}`,
|
|
106
|
+
open.startedAt,
|
|
107
|
+
Math.max(0, endedAt - open.startedAt),
|
|
108
|
+
parentFor(agents, open.startedAt),
|
|
109
|
+
attrs,
|
|
110
|
+
e.ok ? "ok" : "error",
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
default:
|
|
116
|
+
out.push(e);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function spanStart(
|
|
123
|
+
spanId: string,
|
|
124
|
+
name: string,
|
|
125
|
+
at: number,
|
|
126
|
+
parentSpanId?: string,
|
|
127
|
+
attrs?: Record<string, string | number | boolean>,
|
|
128
|
+
): SpanStartEvent {
|
|
129
|
+
return {
|
|
130
|
+
type: "span_start",
|
|
131
|
+
spanId,
|
|
132
|
+
...(parentSpanId !== undefined ? { parentSpanId } : {}),
|
|
133
|
+
name,
|
|
134
|
+
...(attrs ? { attrs } : {}),
|
|
135
|
+
at,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function spanEnd(
|
|
140
|
+
spanId: string,
|
|
141
|
+
name: string,
|
|
142
|
+
startedAt: number,
|
|
143
|
+
durationMs: number,
|
|
144
|
+
parentSpanId?: string,
|
|
145
|
+
attrs?: Record<string, string | number | boolean>,
|
|
146
|
+
status: "ok" | "error" = "ok",
|
|
147
|
+
): SpanEndEvent {
|
|
148
|
+
return {
|
|
149
|
+
type: "span_end",
|
|
150
|
+
spanId,
|
|
151
|
+
...(parentSpanId !== undefined ? { parentSpanId } : {}),
|
|
152
|
+
name,
|
|
153
|
+
startedAt,
|
|
154
|
+
durationMs,
|
|
155
|
+
status,
|
|
156
|
+
...(attrs ? { attrs } : {}),
|
|
157
|
+
at: startedAt + durationMs,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Pair the span records of a (normalized) stream into `SpanRecord`s. A
|
|
162
|
+
* `span_start` with no `span_end` is an open span (no `endedAt`); a `span_end`
|
|
163
|
+
* with no start is complete on its own. `traceId` is the stream's — the run id
|
|
164
|
+
* the caller names, since a stream belongs to one run. */
|
|
165
|
+
export function spansFromEvents(events: readonly RunEvent[], traceId: string): SpanRecord[] {
|
|
166
|
+
const byId = new Map<string, SpanRecord>();
|
|
167
|
+
for (const e of events) if (isSpanRecord(e)) foldSpanRecord(byId, e, traceId);
|
|
168
|
+
return [...byId.values()];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Fold one span record into the set: a start opens the record (unless an end
|
|
172
|
+
* already made it), an end completes it, keeping a start's parent and attrs.
|
|
173
|
+
* The per-frame half of `spansFromEvents`, for a page that folds live. */
|
|
174
|
+
export function foldSpanRecord(byId: Map<string, SpanRecord>, e: SpanStartEvent | SpanEndEvent, traceId: string): void {
|
|
175
|
+
const prior = byId.get(e.spanId);
|
|
176
|
+
if (e.type === "span_start") {
|
|
177
|
+
if (prior) return; // an end already made the record
|
|
178
|
+
byId.set(e.spanId, {
|
|
179
|
+
traceId,
|
|
180
|
+
spanId: e.spanId,
|
|
181
|
+
...(e.parentSpanId !== undefined ? { parentSpanId: e.parentSpanId } : {}),
|
|
182
|
+
name: e.name,
|
|
183
|
+
startedAt: e.at ?? 0,
|
|
184
|
+
attrs: (e.attrs ?? {}) as SpanAttrs,
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
byId.set(e.spanId, {
|
|
189
|
+
traceId,
|
|
190
|
+
spanId: e.spanId,
|
|
191
|
+
...(e.parentSpanId !== undefined
|
|
192
|
+
? { parentSpanId: e.parentSpanId }
|
|
193
|
+
: prior?.parentSpanId !== undefined
|
|
194
|
+
? { parentSpanId: prior.parentSpanId }
|
|
195
|
+
: {}),
|
|
196
|
+
name: e.name,
|
|
197
|
+
startedAt: e.startedAt,
|
|
198
|
+
endedAt: e.startedAt + e.durationMs,
|
|
199
|
+
durationMs: e.durationMs,
|
|
200
|
+
status: e.status,
|
|
201
|
+
...(e.error !== undefined ? { errorMessage: e.error } : {}),
|
|
202
|
+
attrs: { ...(prior?.attrs ?? {}), ...((e.attrs ?? {}) as SpanAttrs) },
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface ElidedRange {
|
|
207
|
+
fromSeq: number;
|
|
208
|
+
toSeq: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The loss intervals of a stream, in stream time: `[windowStart, at(first)]`
|
|
213
|
+
* when the first stored `seq` is above 1 (the registry trimmed the head —
|
|
214
|
+
* always `lost`), `[min(at(prev), at(next)), max(…)]` for each interior `seq`
|
|
215
|
+
* gap (`elided` when the gap lies inside a range the live transport reported,
|
|
216
|
+
* `lost` otherwise), and every `spans_dropped` note's own `from`/`to` (`lost`).
|
|
217
|
+
* `replay_note` markers are never consulted. Events without `at` contribute no
|
|
218
|
+
* interval.
|
|
219
|
+
*/
|
|
220
|
+
export function lossesFromStream(
|
|
221
|
+
events: readonly RunEvent[],
|
|
222
|
+
opts: { windowStart?: number; elided?: readonly ElidedRange[] } = {},
|
|
223
|
+
): LossInterval[] {
|
|
224
|
+
const tracker = createLossTracker();
|
|
225
|
+
for (const e of events) tracker.push(e);
|
|
226
|
+
return tracker.losses(opts);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface LossTracker {
|
|
230
|
+
/** One more event of the stream, in order. */
|
|
231
|
+
push(event: RunEvent): void;
|
|
232
|
+
/** The loss intervals so far, for the window's start and the ranges the live
|
|
233
|
+
* transport has reported by now. O(gaps), never a rescan of the stream. */
|
|
234
|
+
losses(opts?: { windowStart?: number; elided?: readonly ElidedRange[] }): LossInterval[];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The incremental form of `lossesFromStream`: a live page pushes each frame
|
|
238
|
+
* as it arrives and reads the intervals per tick without holding the stream.
|
|
239
|
+
* Kept per event: the first event's `seq` and stamp (the head loss), each
|
|
240
|
+
* interior `seq` gap with the stamps around it (its kind decided at read time,
|
|
241
|
+
* since the `replay_elided` range that covers a gap can arrive after it), and
|
|
242
|
+
* every `spans_dropped` note's own interval — in stream order. */
|
|
243
|
+
export function createLossTracker(): LossTracker {
|
|
244
|
+
let first: { seq: number; at: number | undefined } | undefined;
|
|
245
|
+
let prev: { seq: number | undefined; at: number | undefined } | undefined;
|
|
246
|
+
const entries: Array<
|
|
247
|
+
| { kind: "gap"; fromSeq: number; toSeq: number; from: number; to: number }
|
|
248
|
+
| { kind: "note"; from: number; to: number }
|
|
249
|
+
> = [];
|
|
250
|
+
return {
|
|
251
|
+
push(e) {
|
|
252
|
+
if (!first) {
|
|
253
|
+
first = { seq: e.seq ?? 1, at: e.at };
|
|
254
|
+
} else if (prev && e.seq !== undefined && prev.seq !== undefined && e.seq > prev.seq + 1) {
|
|
255
|
+
if (prev.at !== undefined && e.at !== undefined) {
|
|
256
|
+
entries.push({
|
|
257
|
+
kind: "gap",
|
|
258
|
+
fromSeq: prev.seq + 1,
|
|
259
|
+
toSeq: e.seq - 1,
|
|
260
|
+
from: Math.min(prev.at, e.at),
|
|
261
|
+
to: Math.max(prev.at, e.at),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (
|
|
266
|
+
e.type === "run_note" &&
|
|
267
|
+
e.kind === "spans_dropped" &&
|
|
268
|
+
e.from !== undefined &&
|
|
269
|
+
e.to !== undefined &&
|
|
270
|
+
e.to >= e.from
|
|
271
|
+
) {
|
|
272
|
+
entries.push({ kind: "note", from: e.from, to: e.to });
|
|
273
|
+
}
|
|
274
|
+
prev = { seq: e.seq, at: e.at };
|
|
275
|
+
},
|
|
276
|
+
losses(opts = {}) {
|
|
277
|
+
const elided = opts.elided ?? [];
|
|
278
|
+
const inElided = (from: number, to: number) => elided.some((r) => r.fromSeq <= from && to <= r.toSeq);
|
|
279
|
+
const out: LossInterval[] = [];
|
|
280
|
+
if (
|
|
281
|
+
first &&
|
|
282
|
+
first.seq > 1 &&
|
|
283
|
+
opts.windowStart !== undefined &&
|
|
284
|
+
first.at !== undefined &&
|
|
285
|
+
first.at > opts.windowStart
|
|
286
|
+
) {
|
|
287
|
+
out.push({ from: opts.windowStart, to: first.at, kind: "lost" });
|
|
288
|
+
}
|
|
289
|
+
for (const g of entries) {
|
|
290
|
+
out.push(
|
|
291
|
+
g.kind === "note"
|
|
292
|
+
? { from: g.from, to: g.to, kind: "lost" }
|
|
293
|
+
: { from: g.from, to: g.to, kind: inElided(g.fromSeq, g.toSeq) ? "elided" : "lost" },
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The PrDescription SHAPE, dependency-free. runEvents.ts (the node-free
|
|
2
|
+
// run-event contract compiled by the memory Worker's and web app's own
|
|
3
|
+
// tsconfigs) carries a PrDescription on the `pr_description` event, so the
|
|
4
|
+
// type must be importable without dragging zod into those compile graphs —
|
|
5
|
+
// the schema and validation live in prDescription.ts, which imports these
|
|
6
|
+
// types and enforces (via its annotated parse return) that the zod output
|
|
7
|
+
// stays assignable to them. Add a field here first, then to the schema.
|
|
8
|
+
|
|
9
|
+
/** A hunk the reader is pointed at: a path + inclusive 1-based line range in
|
|
10
|
+
* the PR head. The sha is NOT stored here — it is supplied at render time. */
|
|
11
|
+
export interface TourAnchor {
|
|
12
|
+
path: string;
|
|
13
|
+
from: number;
|
|
14
|
+
to: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One Tour step, reader-first: heading (what the change is), the explanation,
|
|
18
|
+
* an optional "look for" pointer, then the code. */
|
|
19
|
+
export interface TourStep {
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
lookFor?: string;
|
|
23
|
+
anchor: TourAnchor;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A Tour anchor with the sha its permalink was rendered at — what a reader
|
|
27
|
+
* gets back from a rendered body (or from a submitted object at the head it
|
|
28
|
+
* was rendered for), so a surface can tell whether the anchors are at the
|
|
29
|
+
* head it is looking at. */
|
|
30
|
+
export interface RenderedTourAnchor extends TourAnchor {
|
|
31
|
+
sha: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A Tour step whose anchor carries its render sha. Assignable to `TourStep`. */
|
|
35
|
+
export interface RenderedTourStep extends TourStep {
|
|
36
|
+
anchor: RenderedTourAnchor;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PrDescription {
|
|
40
|
+
/** The PR title's single source. Metadata for the PR's own title field —
|
|
41
|
+
* never rendered into the body (GitHub shows the title itself). */
|
|
42
|
+
title: string;
|
|
43
|
+
tldr: string;
|
|
44
|
+
whatWhy: string;
|
|
45
|
+
tour: TourStep[];
|
|
46
|
+
/** Every touched file the Tour steps did not cover, one line each. */
|
|
47
|
+
remaining: { path: string; note: string }[];
|
|
48
|
+
decisions: { title: string; rationale: string }[];
|
|
49
|
+
risks: string;
|
|
50
|
+
validation: {
|
|
51
|
+
summary?: string;
|
|
52
|
+
criteria: { criterion: string; proof: string }[];
|
|
53
|
+
};
|
|
54
|
+
}
|