@inixiative/foundry-core 0.5.0 → 0.5.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/foundry-core",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -1,4 +1,4 @@
1
- import { realpathSync } from 'node:fs';
1
+ import { existsSync, realpathSync } from 'node:fs';
2
2
  import { realpath, stat } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
@@ -33,6 +33,8 @@ export class MarkdownDocs {
33
33
  /** Scan and load all matching files. */
34
34
  async load(): Promise<Map<string, string>> {
35
35
  const results = new Map<string, string>();
36
+ // A docs directory that doesn't exist yet (a fresh install's home) has no docs.
37
+ if (!existsSync(this.dir)) return results;
36
38
  const scanner = new Bun.Glob(this.glob).scan({ cwd: this.dir, absolute: false });
37
39
 
38
40
  for await (const relPath of scanner) {
@@ -65,6 +67,7 @@ export class MarkdownDocs {
65
67
  /** List all matching files. */
66
68
  async list(): Promise<string[]> {
67
69
  const paths: string[] = [];
70
+ if (!existsSync(this.dir)) return paths;
68
71
  const scanner = new Bun.Glob(this.glob).scan({ cwd: this.dir, absolute: false });
69
72
  for await (const relPath of scanner) {
70
73
  paths.push(relPath);
@@ -206,6 +209,8 @@ export interface InstructionFile {
206
209
  readonly path: string;
207
210
  /** Text with block-level HTML comments removed, as Claude Code injects it. */
208
211
  readonly content: string;
212
+ /** The file's text as read, when stripping comments changed it (`content` is the stripped form). */
213
+ readonly raw?: string;
209
214
  /** Import hops from the file the chain started at (0 for that file). */
210
215
  readonly depth: number;
211
216
  /** The file whose `@path` loaded this one. */
@@ -392,8 +397,15 @@ export async function readInstructionFile(
392
397
  return;
393
398
  }
394
399
  seen.add(real);
395
- const content = stripInstructionComments(await Bun.file(real).text());
396
- files.push({ path: file, content, depth, ...(importedBy ? { importedBy } : {}) });
400
+ const raw = await Bun.file(real).text();
401
+ const content = stripInstructionComments(raw);
402
+ files.push({
403
+ path: file,
404
+ content,
405
+ ...(raw !== content ? { raw } : {}),
406
+ depth,
407
+ ...(importedBy ? { importedBy } : {}),
408
+ });
397
409
  if (depth >= maxDepth) return;
398
410
  for (const target of instructionImports(content)) {
399
411
  const next = resolveInstructionImport(target, file, options.home);
@@ -617,7 +617,8 @@ export async function discoverHierarchy(
617
617
  continue;
618
618
  }
619
619
  if (statSync(real).size > INSTRUCTION_FILE_LIMIT) continue;
620
- let content = readFileSync(unit.path, 'utf8');
620
+ const raw = readFileSync(unit.path, 'utf8');
621
+ let content = raw;
621
622
  if (unit.source.maxLines !== undefined)
622
623
  content = content.split('\n').slice(0, unit.source.maxLines).join('\n');
623
624
  if (unit.source.maxBytes !== undefined)
@@ -625,7 +626,14 @@ export async function discoverHierarchy(
625
626
  const final = tierOf(unit.path, node);
626
627
  seen.add(real);
627
628
  kept.set(real, { path: unit.path, node: final.id, index: files.length });
628
- files.push({ path: unit.path, content, depth: 0, node: final.id, tier: final.tier });
629
+ files.push({
630
+ path: unit.path,
631
+ content,
632
+ ...(raw !== content ? { raw } : {}),
633
+ depth: 0,
634
+ node: final.id,
635
+ tier: final.tier,
636
+ });
629
637
  continue;
630
638
  }
631
639
  const allowed =
@@ -665,7 +673,13 @@ export async function discoverHierarchy(
665
673
  // Every file keeps the narrower tier of its node and any node owning its path, found or imported.
666
674
  const final = tierOf(file.path, node);
667
675
  kept.set(realOf(file.path), { path: file.path, node: final.id, index: files.length });
668
- files.push({ ...file, content, node: final.id, tier: final.tier });
676
+ files.push({
677
+ ...file,
678
+ content,
679
+ ...(content !== file.content ? { raw: file.raw ?? file.content } : {}),
680
+ node: final.id,
681
+ tier: final.tier,
682
+ });
669
683
  }
670
684
  }
671
685
  return { files, duplicates, refused, probes: [...probes] };
@@ -850,7 +864,7 @@ export function dedupeHierarchy<F extends HierarchyFile>(
850
864
  continue;
851
865
  }
852
866
  const content = cut(file.content.split('\n'), dropped);
853
- if (content.trim()) keep.unshift({ ...file, content });
867
+ if (content.trim()) keep.unshift({ ...file, content, raw: file.raw ?? file.content });
854
868
  }
855
869
  out = keep;
856
870
  }
@@ -109,8 +109,55 @@ export interface ContextSource {
109
109
  * Must be cheap: it runs before every assembly.
110
110
  */
111
111
  changed?(): boolean;
112
+ /**
113
+ * What the last load was made of, in order: glue text and attributed parts whose concatenation is
114
+ * exactly the loaded text. Absent when the source cannot attribute its text.
115
+ */
116
+ segments?(): readonly SourceSegment[] | undefined;
117
+ }
118
+
119
+ /**
120
+ * One attributed piece of composed text. `subject` names where it came from (`file:<path>`,
121
+ * `prompt:agent:<id>`, `source-content:<id>`). `retrieved` marks text chosen per message (it is not
122
+ * a slow-moving source). `raw` is the source's own text when the composed `text` is a cleaned or
123
+ * trimmed form of it (comments stripped, a duplicate section removed).
124
+ */
125
+ export interface SourcePart {
126
+ readonly subject: string;
127
+ readonly text: string;
128
+ readonly retrieved?: boolean;
129
+ readonly raw?: string;
130
+ /** The ladder rung of a file part, and its corpus tier. */
131
+ readonly node?: string;
132
+ readonly tier?: string;
133
+ }
134
+
135
+ /** Glue text (headings, separators) or an attributed part. */
136
+ export type SourceSegment = string | SourcePart;
137
+
138
+ /** What a composed agent call was made of, slot by slot (in memory; persisted as version ids). */
139
+ export interface CompositionSnapshot {
140
+ /** The role composed for (an agent id). */
141
+ readonly role?: string;
142
+ readonly instructions: readonly SourceSegment[];
143
+ readonly domainKnowledge: readonly SourceSegment[];
144
+ /** What the composer left out of each slot, and why (excluded by role, a duplicate, refused, over budget). */
145
+ readonly omitted?: {
146
+ readonly instructions: readonly OmittedSource[];
147
+ readonly domainKnowledge: readonly OmittedSource[];
148
+ };
112
149
  }
113
150
 
151
+ export interface OmittedSource {
152
+ readonly subject: string;
153
+ readonly reason: string;
154
+ readonly duplicateOf?: string;
155
+ }
156
+
157
+ /** The text a segment list composes. */
158
+ export const segmentsText = (segments: readonly SourceSegment[]): string =>
159
+ segments.map((segment) => (typeof segment === 'string' ? segment : segment.text)).join('');
160
+
114
161
  /** Selection provenance carried on a layer instance for inspection. */
115
162
  export interface LayerSelectionState {
116
163
  readonly focusHash?: string;
@@ -320,6 +367,8 @@ export class ContextLayer {
320
367
  readonly perTurn: boolean;
321
368
 
322
369
  private _content: string = '';
370
+ /** Each source's text from the last warm, in source order; undefined after a manual `set`. */
371
+ private _loaded: string[] | undefined;
323
372
  private _state: LayerState = 'cold';
324
373
  private _hash: string = '';
325
374
  private _lastWarmed: number | null = null;
@@ -496,6 +545,32 @@ export class ContextLayer {
496
545
  return this._content;
497
546
  }
498
547
 
548
+ /**
549
+ * What the current content is made of: each source's segments (or the source's whole text as one
550
+ * part, `unattributed:<source id>`, when it cannot attribute it), joined as the content was. Content written with
551
+ * `set` is one part, `layer-content:<id>`. Concatenated, always exactly `content`.
552
+ */
553
+ segments(): SourceSegment[] {
554
+ const whole = (): SourceSegment[] =>
555
+ this._content ? [{ subject: `layer-content:${this.id}`, text: this._content }] : [];
556
+ const loaded = this._loaded;
557
+ if (!loaded || loaded.length !== this._sources.length) return whole();
558
+ const out: SourceSegment[] = [];
559
+ loaded.forEach((text, i) => {
560
+ if (i > 0) out.push('\n\n');
561
+ const source = this._sources[i]!;
562
+ const own = source.segments?.();
563
+ if (own && segmentsText(own) === text) out.push(...own);
564
+ else if (text)
565
+ out.push({
566
+ subject: `unattributed:${source.id}`,
567
+ text,
568
+ ...(source.focusable ? { retrieved: true } : {}),
569
+ });
570
+ });
571
+ return segmentsText(out) === this._content ? out : whole();
572
+ }
573
+
499
574
  get lastAccessed(): number | null {
500
575
  return this._lastAccessed;
501
576
  }
@@ -595,6 +670,7 @@ export class ContextLayer {
595
670
  }
596
671
 
597
672
  this._content = parts.join('\n\n');
673
+ this._loaded = parts;
598
674
  this._hash = computeHash(this._content);
599
675
  this._version = undefined; // even identical unversioned source bytes are a new write
600
676
  this._selection = reports.length
@@ -632,6 +708,7 @@ export class ContextLayer {
632
708
  set(content: string, author: string = 'system'): void {
633
709
  const previous = this._content;
634
710
  this._content = content;
711
+ this._loaded = undefined;
635
712
  this._hash = computeHash(content);
636
713
  this._selection = undefined; // manual content has no selection provenance
637
714
  this._version = undefined; // a new write carries no version until its owner marks it
@@ -0,0 +1,229 @@
1
+ import type { WardenProbe } from './warden-probes';
2
+
3
+ /** One Warden of the roster as the labeler sees it. */
4
+ export interface WardenRosterEntry {
5
+ readonly id: string;
6
+ readonly domain: string;
7
+ readonly prompt: string;
8
+ readonly enabled: boolean;
9
+ }
10
+
11
+ /** Whether a Warden had something of its own to add at the moment, and what it should have known. */
12
+ export interface WardenLabel {
13
+ readonly relevant: boolean;
14
+ readonly shouldKnow?: string;
15
+ }
16
+
17
+ export type WardenTriggerKind = 'user-message' | 'agent-action' | 'agent-plan';
18
+
19
+ /**
20
+ * A labelled moment for the Warden prefilter: which Warden domains the trigger touches. Never commit
21
+ * fixtures: they hold session text and company code excerpts.
22
+ */
23
+ export interface WardenFixture {
24
+ readonly id: string;
25
+ /** Which context's Wardens may see it (UserEvidence fixtures only ever see the UserEvidence ladder). */
26
+ readonly ctx: string;
27
+ readonly project?: string;
28
+ readonly source: 'git' | 'session' | 'history';
29
+ /** The moment before the mistake, as a Warden would see it. */
30
+ readonly trigger: { readonly kind: WardenTriggerKind; readonly text: string };
31
+ /** The thread or change up to the moment; never what happened next. */
32
+ readonly context: string;
33
+ /** What happened next (the correction, the fix, the review). The labeler sees it; Wardens never do. */
34
+ readonly hidden?: string;
35
+ /** Every Warden of the roster, keyed by agent id. */
36
+ readonly labels: Readonly<Record<string, WardenLabel>>;
37
+ /** The FEEDBACK-LOG rule numbers the moment's mistake repeats, when any. */
38
+ readonly feedbackRules?: readonly number[];
39
+ readonly provenance: Readonly<Record<string, string | number | null>>;
40
+ readonly labeler?: { readonly model: string; readonly run: string; readonly promptHash: string };
41
+ }
42
+
43
+ export type UnlabelledFixture = Omit<WardenFixture, 'labels' | 'labeler' | 'feedbackRules'> & {
44
+ /** Earlier asks or commits in the same thread, for the labeler to summarize into `context`. */
45
+ readonly precedingRaw?: string;
46
+ };
47
+
48
+ export const WARDEN_LABEL_PROMPT = `You label moments from AI coding sessions and code changes for a reviewer-routing experiment.
49
+
50
+ A roster of Wardens advises a coding agent. Each Warden owns one narrow domain (given below with its charter). At each moment (a user's message to the agent, or an agent's action or plan), a router must decide which Wardens to wake. You produce the ground truth for that router.
51
+
52
+ For each moment you get: the thread context, the trigger (the moment itself), and WHAT HAPPENED NEXT (the person's correction, a later fix, a review comment, or nothing notable). Use what happened next to understand what was wrong or what was needed, but judge each Warden by the moment: would a competent Warden of that domain, seeing the trigger and context, have had something true, specific and useful to say inside its own domain? That includes the Warden that would have prevented the mistake, and any other Warden whose domain the moment genuinely touches (for example a schema change touches Schema even when the correction was about reuse). It does NOT include a Warden that could only say something generic, or whose domain the moment merely mentions in passing.
53
+
54
+ Most Wardens are irrelevant to most moments. A moment where nothing went wrong can still be relevant to a Warden (a plan heading to merge without the full check touches Ship), but plain chatter, a status question, or an answer to the agent's question usually touches none.
55
+
56
+ For each relevant Warden, write shouldKnow: the specific thing it should have known or said, at most 200 characters, naming the existing primitive, file, symbol, rule or check when there is one (e.g. "Use the existing debounce wrapper in packages/shared/debounce.ts instead of a new timer"). Never quote secrets.
57
+
58
+ Also write context: 1-3 sentences summarizing the thread or change UP TO the moment (what is being worked on, where), without revealing what happened next. And feedbackRules: the numbers of the recurring rules (list given) that the moment's mistake repeats, or [].
59
+
60
+ Respond with JSON only:
61
+ { "results": { "<fixture id>": { "context": string, "relevant": { "<domain>": "<shouldKnow>" }, "feedbackRules": number[] } } }
62
+ List only relevant domains under "relevant" (an empty object when none). Use the domain names exactly as given.`;
63
+
64
+ /** The stable start of every label input: the roster and the rule list, so primed sessions reuse it. */
65
+ export function wardenLabelPrefix(
66
+ roster: readonly WardenRosterEntry[],
67
+ rules: readonly { n: number; rule: string; count: string }[],
68
+ ): string {
69
+ const wardens = roster.map((w) => `- ${w.domain}: ${w.prompt.replace(/\s+/g, ' ')}`).join('\n');
70
+ const recurring = rules.map((r) => `${r.n}. ${r.rule} (said ${r.count}×)`).join('\n');
71
+ return `## Wardens\n${wardens}\n\n## Recurring rules (FEEDBACK-LOG)\n${recurring}\n\nLabel each moment below.\n\n`;
72
+ }
73
+
74
+ export function wardenLabelMessages(
75
+ prefix: string,
76
+ fixtures: readonly UnlabelledFixture[],
77
+ limits: { trigger?: number; context?: number; hidden?: number } = {},
78
+ ): { role: 'system' | 'user'; content: string }[] {
79
+ const body = fixtures
80
+ .map(
81
+ (f) =>
82
+ `## ${f.id} (${f.ctx}${f.project ? `/${f.project}` : ''}, ${f.source}, trigger: ${f.trigger.kind})\n### Context\n${
83
+ [f.context, f.precedingRaw]
84
+ .filter(Boolean)
85
+ .join('\n')
86
+ .slice(-(limits.context ?? 1500)) || '(none)'
87
+ }\n\n### Trigger\n${f.trigger.text.slice(-(limits.trigger ?? 3000))}\n\n### What happened next\n${f.hidden?.slice(0, limits.hidden ?? 1500) || '(nothing notable)'}`,
88
+ )
89
+ .join('\n\n');
90
+ return [
91
+ { role: 'system', content: WARDEN_LABEL_PROMPT },
92
+ { role: 'user', content: `${prefix}${body}` },
93
+ ];
94
+ }
95
+
96
+ export interface ParsedWardenLabels {
97
+ readonly context: string;
98
+ readonly labels: Record<string, WardenLabel>;
99
+ readonly feedbackRules: number[];
100
+ }
101
+
102
+ /** One answer per fixture id; undefined where the answer is missing or malformed. Unknown domains are dropped. */
103
+ export function parseWardenLabels(
104
+ content: string,
105
+ roster: readonly WardenRosterEntry[],
106
+ ids: readonly string[],
107
+ ): (ParsedWardenLabels | undefined)[] {
108
+ const start = content.indexOf('{');
109
+ const end = content.lastIndexOf('}');
110
+ let raw: unknown;
111
+ try {
112
+ raw = JSON.parse(content.slice(start, end + 1));
113
+ } catch {
114
+ return ids.map(() => undefined);
115
+ }
116
+ const results = (raw as { results?: Record<string, unknown> } | null)?.results ?? {};
117
+ const byDomain = new Map(roster.map((w) => [w.domain, w.id]));
118
+ return ids.map((id) => {
119
+ const item = results[id] as
120
+ | { context?: unknown; relevant?: unknown; feedbackRules?: unknown }
121
+ | undefined;
122
+ if (!item || typeof item.relevant !== 'object' || item.relevant === null) return undefined;
123
+ const relevant = item.relevant as Record<string, unknown>;
124
+ const labels: Record<string, WardenLabel> = {};
125
+ for (const w of roster) labels[w.id] = { relevant: false };
126
+ for (const [domain, know] of Object.entries(relevant)) {
127
+ const wardenId = byDomain.get(domain);
128
+ if (!wardenId) continue;
129
+ labels[wardenId] = {
130
+ relevant: true,
131
+ ...(typeof know === 'string' && know.trim()
132
+ ? { shouldKnow: know.trim().slice(0, 300) }
133
+ : {}),
134
+ };
135
+ }
136
+ return {
137
+ context: typeof item.context === 'string' ? item.context.trim() : '',
138
+ labels,
139
+ feedbackRules: Array.isArray(item.feedbackRules)
140
+ ? item.feedbackRules.filter((n): n is number => Number.isInteger(n))
141
+ : [],
142
+ };
143
+ });
144
+ }
145
+
146
+ export interface DomainAgreement {
147
+ readonly wardenId: string;
148
+ /** Fixtures both sides labelled. */
149
+ readonly n: number;
150
+ readonly agree: number;
151
+ readonly positivesA: number;
152
+ readonly positivesB: number;
153
+ readonly bothPositive: number;
154
+ /** Cohen's kappa; null when chance agreement is total (one class on both sides). */
155
+ readonly kappa: number | null;
156
+ }
157
+
158
+ /** Per-Warden agreement between two labelings of the same fixtures, plus the pooled figures. */
159
+ export function labelAgreement(
160
+ a: ReadonlyMap<string, Readonly<Record<string, WardenLabel>>>,
161
+ b: ReadonlyMap<string, Readonly<Record<string, WardenLabel>>>,
162
+ wardenIds: readonly string[],
163
+ ): {
164
+ perWarden: DomainAgreement[];
165
+ pooled: DomainAgreement;
166
+ exactFixtures: number;
167
+ fixtures: number;
168
+ } {
169
+ const shared = [...a.keys()].filter((id) => b.has(id));
170
+ const tally = (wardenId: string, ids: readonly string[], wardens: readonly string[]) => {
171
+ let n = 0;
172
+ let agree = 0;
173
+ let positivesA = 0;
174
+ let positivesB = 0;
175
+ let bothPositive = 0;
176
+ for (const id of ids)
177
+ for (const w of wardens) {
178
+ const x = a.get(id)?.[w]?.relevant ?? false;
179
+ const y = b.get(id)?.[w]?.relevant ?? false;
180
+ n++;
181
+ if (x === y) agree++;
182
+ if (x) positivesA++;
183
+ if (y) positivesB++;
184
+ if (x && y) bothPositive++;
185
+ }
186
+ const observed = n ? agree / n : 0;
187
+ const chance = n
188
+ ? (positivesA / n) * (positivesB / n) + (1 - positivesA / n) * (1 - positivesB / n)
189
+ : 1;
190
+ const kappa =
191
+ chance >= 1 ? null : Math.round(((observed - chance) / (1 - chance)) * 1000) / 1000;
192
+ return { wardenId, n, agree, positivesA, positivesB, bothPositive, kappa };
193
+ };
194
+ const exactFixtures = shared.filter((id) =>
195
+ wardenIds.every(
196
+ (w) => (a.get(id)?.[w]?.relevant ?? false) === (b.get(id)?.[w]?.relevant ?? false),
197
+ ),
198
+ ).length;
199
+ return {
200
+ perWarden: wardenIds.map((w) => tally(w, shared, [w])),
201
+ pooled: tally('*', shared, wardenIds),
202
+ exactFixtures,
203
+ fixtures: shared.length,
204
+ };
205
+ }
206
+
207
+ /** The charter eval's probe shape, so `warden-charter.ts` runs on fixtures too. */
208
+ export function fixtureProbe(
209
+ fixture: WardenFixture,
210
+ domainOf: (wardenId: string) => string,
211
+ ): WardenProbe {
212
+ const relevant = Object.entries(fixture.labels).filter(([, l]) => l.relevant);
213
+ return {
214
+ id: fixture.id,
215
+ kind: relevant.length ? 'positive' : 'negative',
216
+ ctx: fixture.ctx,
217
+ ...(fixture.project ? { project: fixture.project } : {}),
218
+ moment: fixture.trigger.text,
219
+ ...(relevant.length
220
+ ? {
221
+ gold: {
222
+ domains: relevant.map(([id]) => domainOf(id)),
223
+ statements: relevant.map(([, l]) => l.shouldKnow).filter((s): s is string => !!s),
224
+ },
225
+ }
226
+ : {}),
227
+ ...(fixture.hidden ? { hidden: fixture.hidden } : {}),
228
+ };
229
+ }
@@ -0,0 +1,194 @@
1
+ // The Warden pre-filter: one cheap local yes/no per Warden before it is asked. Data and pure
2
+ // functions only, so Oracle scores runs the same way; Foundry's provider makes the call. It fails
3
+ // open: a Warden is skipped only on a confident no, and anything else (an unsure score, an error, no
4
+ // answer) asks the Warden as before.
5
+
6
+ import type { WardenTriggerKind } from './warden-labels';
7
+
8
+ /**
9
+ * Where a moment arrives: before (the user's message) or after (the agent's action or plan, and its
10
+ * finished turn). The labelled moments are `WardenFixture`s (`warden-labels`).
11
+ */
12
+ export type PrefilterTrigger = WardenTriggerKind | 'post-turn';
13
+
14
+ /** One (moment, Warden) pair as a run scored it. */
15
+ export interface PrefilterPair {
16
+ readonly momentId: string;
17
+ readonly wardenId: string;
18
+ /** Labelled relevance; undefined for an unlabelled pair, which still counts toward the skip rate. */
19
+ readonly relevant?: boolean;
20
+ /** P(the moment is in this Warden's domain); undefined when the call failed (the Warden is asked). */
21
+ readonly score?: number;
22
+ }
23
+
24
+ /** Fail open: skip only when the score is a confident no. */
25
+ export function prefilterSkips(score: number | undefined, skipBelow: number): boolean {
26
+ return score !== undefined && score < skipBelow;
27
+ }
28
+
29
+ export interface PrefilterPoint {
30
+ readonly threshold: number;
31
+ /** Share of all pairs skipped: Warden calls saved. */
32
+ readonly skipRate: number;
33
+ /** Share of relevant pairs still asked. */
34
+ readonly recall: number | null;
35
+ /** Share of labelled not-relevant pairs skipped. */
36
+ readonly correctSkips: number | null;
37
+ }
38
+
39
+ export interface PrefilterScore {
40
+ readonly pairs: number;
41
+ readonly relevant: number;
42
+ readonly labelledIrrelevant: number;
43
+ readonly errors: number;
44
+ /** Ranking quality over labelled pairs (relevant against labelled not relevant). */
45
+ readonly auroc: number | null;
46
+ /** Recall when the threshold skips this share of all pairs. */
47
+ readonly recallAtSkip: Readonly<Record<string, number | null>>;
48
+ /** The share of pairs skipped while still asking this share of relevant pairs. */
49
+ readonly skipAtRecall: Readonly<Record<string, { threshold: number; skipRate: number } | null>>;
50
+ readonly sweep: readonly PrefilterPoint[];
51
+ }
52
+
53
+ const round = (n: number) => Math.round(n * 1000) / 1000;
54
+ const ratio = (n: number, d: number) => (d ? round(n / d) : null);
55
+
56
+ export const PREFILTER_SWEEP = [
57
+ 0.01, 0.02, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9,
58
+ ] as const;
59
+
60
+ /** Recall against skip rate, both ways, and the threshold sweep. */
61
+ export function scorePrefilter(
62
+ pairs: readonly PrefilterPair[],
63
+ options: { skipRates?: readonly number[]; recalls?: readonly number[] } = {},
64
+ ): PrefilterScore {
65
+ const positives = pairs.filter((p) => p.relevant === true);
66
+ const negatives = pairs.filter((p) => p.relevant === false);
67
+ const at = (threshold: number): PrefilterPoint => {
68
+ const skip = (p: PrefilterPair) => prefilterSkips(p.score, threshold);
69
+ return {
70
+ threshold,
71
+ skipRate: ratio(pairs.filter(skip).length, pairs.length) ?? 0,
72
+ recall: ratio(positives.filter((p) => !skip(p)).length, positives.length),
73
+ correctSkips: ratio(negatives.filter(skip).length, negatives.length),
74
+ };
75
+ };
76
+ const scoreOf = (p: PrefilterPair) => p.score ?? Number.POSITIVE_INFINITY;
77
+ // Recall at a skip rate: the threshold is the score that skips that share of all pairs.
78
+ const ascending = pairs.map(scoreOf).sort((a, b) => a - b);
79
+ const recallAtSkip: Record<string, number | null> = {};
80
+ for (const rate of options.skipRates ?? [0.3, 0.5, 0.7]) {
81
+ const threshold =
82
+ ascending[Math.min(ascending.length - 1, Math.floor(rate * ascending.length))];
83
+ recallAtSkip[String(rate)] =
84
+ threshold === undefined || !Number.isFinite(threshold) ? null : at(threshold).recall;
85
+ }
86
+ // Skip rate at a recall: the highest threshold that still asks that share of relevant pairs.
87
+ const positiveScores = positives.map(scoreOf).sort((a, b) => a - b);
88
+ const skipAtRecall: Record<string, { threshold: number; skipRate: number } | null> = {};
89
+ for (const target of options.recalls ?? [0.8, 0.9, 0.95]) {
90
+ const threshold = positiveScores[Math.floor((1 - target) * positiveScores.length + 1e-9)];
91
+ skipAtRecall[String(target)] =
92
+ threshold === undefined || !Number.isFinite(threshold)
93
+ ? null
94
+ : { threshold: round(threshold), skipRate: at(threshold).skipRate };
95
+ }
96
+ return {
97
+ pairs: pairs.length,
98
+ relevant: positives.length,
99
+ labelledIrrelevant: negatives.length,
100
+ errors: pairs.filter((p) => p.score === undefined).length,
101
+ auroc: auroc(positives, negatives),
102
+ recallAtSkip,
103
+ skipAtRecall,
104
+ sweep: PREFILTER_SWEEP.map(at),
105
+ };
106
+ }
107
+
108
+ function auroc(positives: readonly PrefilterPair[], negatives: readonly PrefilterPair[]) {
109
+ const pos = positives.flatMap((p) => (p.score === undefined ? [] : [p.score]));
110
+ const neg = negatives.flatMap((p) => (p.score === undefined ? [] : [p.score]));
111
+ if (!pos.length || !neg.length) return null;
112
+ let wins = 0;
113
+ for (const a of pos) for (const b of neg) wins += a > b ? 1 : a === b ? 0.5 : 0;
114
+ return round(wins / (pos.length * neg.length));
115
+ }
116
+
117
+ /**
118
+ * The same pair scored N times: the share whose skip/ask decision never changed at the threshold,
119
+ * and the mean spread (max − min) of its score.
120
+ */
121
+ export function prefilterConsistency(
122
+ repeats: ReadonlyMap<string, readonly (number | undefined)[]>,
123
+ skipBelow: number,
124
+ ) {
125
+ let same = 0,
126
+ spread = 0,
127
+ n = 0;
128
+ for (const scores of repeats.values()) {
129
+ if (!scores.length) continue;
130
+ n++;
131
+ if (new Set(scores.map((s) => prefilterSkips(s, skipBelow))).size === 1) same++;
132
+ const values = scores.filter((s): s is number => s !== undefined);
133
+ spread += values.length ? Math.max(...values) - Math.min(...values) : 1;
134
+ }
135
+ return { pairs: n, sameDecision: ratio(same, n), meanSpread: n ? round(spread / n) : null };
136
+ }
137
+
138
+ export function percentiles(values: readonly number[]) {
139
+ const sorted = [...values].sort((a, b) => a - b);
140
+ const pick = (p: number) =>
141
+ sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))] ?? 0;
142
+ return { p50: pick(50), p95: pick(95), max: sorted.at(-1) ?? 0 };
143
+ }
144
+
145
+ /** Characters of a Warden's instructions a question carries: the decision model reads 192 tokens of it. */
146
+ export const PREFILTER_DESCRIPTION_CHARS = 480;
147
+ /** Characters of the moment, head and tail, and of the thread context, a state carries. */
148
+ export const PREFILTER_MOMENT_CHARS = 1600;
149
+ export const PREFILTER_CONTEXT_CHARS = 400;
150
+
151
+ /**
152
+ * A Warden's domain as the question states it: its instructions without the "You are the … Warden."
153
+ * opener, so the question reads as a topic.
154
+ */
155
+ export function prefilterDescription(instructions: string): string {
156
+ const text = instructions
157
+ .replace(/\s+/g, ' ')
158
+ .trim()
159
+ .replace(/^You are the [^.]*\.\s*/, '');
160
+ return clip(text, PREFILTER_DESCRIPTION_CHARS);
161
+ }
162
+
163
+ /**
164
+ * The System One yes/no asked per Warden, as a topic ("Is this about …"). On a small hand-checked
165
+ * grid it separated in-domain moments far better than asking about "a reviewer's domain".
166
+ */
167
+ export function prefilterQuestion(description: string) {
168
+ return { type: 'noul' as const, instructions: `Is this about ${description}` };
169
+ }
170
+
171
+ /** The System One state for a moment: a bounded view of what the thread holds, then the moment. */
172
+ export function prefilterState(trigger: PrefilterTrigger, text: string, context?: string): string {
173
+ const label = {
174
+ 'user-message': "The user's message",
175
+ 'agent-action': "The agent's action",
176
+ 'agent-plan': "The agent's plan",
177
+ 'post-turn': "The agent's finished turn",
178
+ }[trigger];
179
+ const moment = window(text.trim(), PREFILTER_MOMENT_CHARS);
180
+ return context?.trim()
181
+ ? `${label}:\n${moment}\n\nThread so far: ${clip(context.trim(), PREFILTER_CONTEXT_CHARS)}`
182
+ : `${label}:\n${moment}`;
183
+ }
184
+
185
+ function clip(text: string, max: number): string {
186
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
187
+ }
188
+
189
+ /** Head and tail of a long text: an agent turn states its plan first and its result last. */
190
+ function window(text: string, max: number): string {
191
+ if (text.length <= max) return text;
192
+ const half = Math.floor((max - 3) / 2);
193
+ return `${text.slice(0, half)} … ${text.slice(-half)}`;
194
+ }
@@ -0,0 +1,212 @@
1
+ // Warden probes: the offline measurement of how well a Warden charter keeps the chorus quiet, in
2
+ // scope and right. Data and pure functions only, so Oracle can score runs on a core-only dependency;
3
+ // callers bring the probes, the Warden calls and the judge call.
4
+ //
5
+ // A probe is one moment of a real session. A positive probe is an agent turn that drew a sharp
6
+ // correction of an already-recorded rule; a negative probe is an agent turn the user let pass.
7
+
8
+ /** One real moment a chorus is asked about. Never commit probes: they hold session text. */
9
+ export interface WardenProbe {
10
+ readonly id: string;
11
+ readonly kind: 'positive' | 'negative';
12
+ /** Which context's Wardens may see it (UserEvidence probes only ever see the UserEvidence ladder). */
13
+ readonly ctx: string;
14
+ readonly project?: string;
15
+ /** The moment: what the agent just did or said, as the Warden sees it. */
16
+ readonly moment: string;
17
+ /** Positive probes: the Warden domains that own the rule the user enforced next, and its one-line statements. */
18
+ readonly gold?: { readonly domains: readonly string[]; readonly statements: readonly string[] };
19
+ /** The user's actual next message. The judge sees it; Wardens never do. */
20
+ readonly hidden?: string;
21
+ }
22
+
23
+ /** One Warden's answer on one probe, and the judge's verdict when it spoke. */
24
+ export interface WardenProbeAnswer {
25
+ readonly probeId: string;
26
+ readonly domain: string;
27
+ readonly noComment: boolean;
28
+ /** The protocol was broken; counted apart, never as silence or speech. */
29
+ readonly error?: string;
30
+ readonly text?: string;
31
+ readonly elapsedMs?: number;
32
+ readonly judged?: WardenJudgement;
33
+ }
34
+
35
+ /** One probe run as a turn: every Warden asked at once, as the live chorus is. */
36
+ export interface WardenProbeTurn {
37
+ readonly probeId: string;
38
+ /** Warden calls made for this probe. */
39
+ readonly calls: number;
40
+ /** Wall clock from the first ask to the last answer. */
41
+ readonly wallMs: number;
42
+ /** Calls the decision queue never admitted. */
43
+ readonly notAdmitted: number;
44
+ }
45
+
46
+ export interface WardenJudgement {
47
+ /** The remark says what the user's next message enforced (the same rule or the same fix). */
48
+ readonly matches: boolean;
49
+ /** The remark stays inside the speaking Warden's domain. */
50
+ readonly inDomain: boolean;
51
+ readonly reason?: string;
52
+ }
53
+
54
+ export interface CharterScore {
55
+ readonly probes: number;
56
+ readonly calls: number;
57
+ readonly errors: number;
58
+ /**
59
+ * Of the Warden answers where nothing of theirs applied (a negative probe, or a positive one owned
60
+ * by another domain, without a remark matched to the real correction), the share that were
61
+ * NO_COMMENT. A protocol error there counts as a failed silence.
62
+ */
63
+ readonly silencePrecision: number | null;
64
+ /** Of the remarks made, the share that matched the user's later real correction. */
65
+ readonly speakPrecision: number | null;
66
+ /** Remarks outside the speaking Warden's domain. */
67
+ readonly scopeViolations: number;
68
+ /** Positive probes where a gold (owning) Warden named the correction before the user did. */
69
+ readonly recall: number | null;
70
+ readonly remarks: number;
71
+ readonly silences: number;
72
+ /** Remarks the judge could not grade: counted as unmatched, reported apart. */
73
+ readonly unjudged: number;
74
+ /** Per-turn load at roster scale: calls and wall clock, when the run recorded turns. */
75
+ readonly turns?: {
76
+ readonly count: number;
77
+ readonly callsPerTurn: number;
78
+ readonly p50Ms: number;
79
+ readonly p95Ms: number;
80
+ readonly maxMs: number;
81
+ readonly notAdmitted: number;
82
+ };
83
+ }
84
+
85
+ function percentile(sorted: readonly number[], p: number): number {
86
+ return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))] ?? 0;
87
+ }
88
+
89
+ const ratio = (n: number, d: number) => (d ? Math.round((n / d) * 1000) / 1000 : null);
90
+
91
+ /**
92
+ * Score one charter run. "Nothing applied" means a negative probe, or a positive probe whose gold
93
+ * domains do not include this Warden; a remark there that the judge matched to the real correction
94
+ * is still a correct remark, not a broken silence.
95
+ */
96
+ export function scoreCharterRun(
97
+ probes: readonly WardenProbe[],
98
+ answers: readonly WardenProbeAnswer[],
99
+ turns: readonly WardenProbeTurn[] = [],
100
+ ): CharterScore {
101
+ const byId = new Map(probes.map((p) => [p.id, p]));
102
+ let calls = 0,
103
+ errors = 0,
104
+ quietCases = 0,
105
+ quietRight = 0,
106
+ remarks = 0,
107
+ remarksRight = 0,
108
+ scope = 0,
109
+ silences = 0,
110
+ unjudged = 0;
111
+ const recalled = new Set<string>();
112
+ for (const a of answers) {
113
+ calls++;
114
+ const probe = byId.get(a.probeId);
115
+ if (!probe) continue;
116
+ const owned = probe.kind === 'positive' && (probe.gold?.domains ?? []).includes(a.domain);
117
+ if (a.error) {
118
+ // A broken answer is neither silence nor a remark; where nothing applied, it is a failed silence.
119
+ errors++;
120
+ if (!owned) quietCases++;
121
+ continue;
122
+ }
123
+ if (a.noComment) silences++;
124
+ else {
125
+ remarks++;
126
+ if (!a.judged) unjudged++;
127
+ if (a.judged?.matches) {
128
+ remarksRight++;
129
+ if (owned) recalled.add(probe.id);
130
+ }
131
+ if (a.judged && !a.judged.inDomain) scope++;
132
+ }
133
+ if (!owned && !(a.judged?.matches ?? false)) {
134
+ quietCases++;
135
+ if (a.noComment) quietRight++;
136
+ }
137
+ }
138
+ const positives = probes.filter((p) => p.kind === 'positive').length;
139
+ return {
140
+ probes: probes.length,
141
+ calls,
142
+ errors,
143
+ silencePrecision: ratio(quietRight, quietCases),
144
+ speakPrecision: ratio(remarksRight, remarks),
145
+ scopeViolations: scope,
146
+ recall: ratio(recalled.size, positives),
147
+ remarks,
148
+ silences,
149
+ unjudged,
150
+ ...(turns.length
151
+ ? {
152
+ turns: (() => {
153
+ const walls = turns.map((t) => t.wallMs).sort((a, b) => a - b);
154
+ return {
155
+ count: turns.length,
156
+ callsPerTurn:
157
+ Math.round((turns.reduce((n, t) => n + t.calls, 0) / turns.length) * 10) / 10,
158
+ p50Ms: percentile(walls, 50),
159
+ p95Ms: percentile(walls, 95),
160
+ maxMs: walls.at(-1) ?? 0,
161
+ notAdmitted: turns.reduce((n, t) => n + t.notAdmitted, 0),
162
+ };
163
+ })(),
164
+ }
165
+ : {}),
166
+ };
167
+ }
168
+
169
+ /** The judge's instructions: one remark against the user's real next message. */
170
+ export const WARDEN_JUDGE_PROMPT = `You grade one remark a narrow expert reviewer (a "Warden") made about an AI coding agent's turn.
171
+ You see the Warden's domain, the agent's turn, the remark, and what the user actually said next.
172
+ Answer two questions:
173
+ - matches: does the remark say what the user's next message enforced: the same rule, or the same fix? A remark that is merely related, or generic, does not match.
174
+ - inDomain: does the remark stay inside the Warden's stated domain?
175
+ Respond with JSON only: { "matches": boolean, "inDomain": boolean, "reason": string } where reason is one short sentence.`;
176
+
177
+ /** The judge's input for one remark. */
178
+ export function wardenJudgeMessage(input: {
179
+ domain: string;
180
+ domainDescription: string;
181
+ moment: string;
182
+ remark: string;
183
+ hidden: string;
184
+ }): string {
185
+ return [
186
+ `## Warden domain: ${input.domain}`,
187
+ input.domainDescription,
188
+ '## Agent turn',
189
+ input.moment,
190
+ '## Remark',
191
+ input.remark,
192
+ "## The user's actual next message",
193
+ input.hidden || '(the user let the turn pass without correcting it)',
194
+ ].join('\n\n');
195
+ }
196
+
197
+ /** Parse a judge answer strictly; undefined when it is not the shape. */
198
+ export function parseWardenJudgement(text: string): WardenJudgement | undefined {
199
+ const trimmed = text.trim();
200
+ const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n?\s*```$/.exec(trimmed);
201
+ try {
202
+ const value = JSON.parse(fenced ? fenced[1]! : trimmed) as Record<string, unknown>;
203
+ if (typeof value.matches !== 'boolean' || typeof value.inDomain !== 'boolean') return undefined;
204
+ return {
205
+ matches: value.matches,
206
+ inDomain: value.inDomain,
207
+ ...(typeof value.reason === 'string' ? { reason: value.reason.slice(0, 300) } : {}),
208
+ };
209
+ } catch {
210
+ return undefined;
211
+ }
212
+ }
package/src/index.ts CHANGED
@@ -116,6 +116,7 @@ export {
116
116
  } from './classifier';
117
117
  // Context primitives
118
118
  export {
119
+ type CompositionSnapshot,
119
120
  ContextLayer,
120
121
  type ContextLayerConfig,
121
122
  type ContextSource,
@@ -131,9 +132,13 @@ export {
131
132
  type LayerState,
132
133
  type LayerVersionMark,
133
134
  type LogicalMessageIdentity,
135
+ type OmittedSource,
134
136
  REQUIRED_CONTEXT_BLOCKED,
135
137
  type SourceLoadHint,
138
+ type SourcePart,
139
+ type SourceSegment,
136
140
  type SourceSelectionReport,
141
+ segmentsText,
137
142
  type VersionEntry,
138
143
  type VersionLog,
139
144
  } from './context-layer';
@@ -184,6 +189,49 @@ export {
184
189
  scrubSecrets,
185
190
  type TranscriptTurn,
186
191
  } from './feedback/correction';
192
+ export {
193
+ type DomainAgreement,
194
+ fixtureProbe,
195
+ labelAgreement,
196
+ type ParsedWardenLabels,
197
+ parseWardenLabels,
198
+ type UnlabelledFixture,
199
+ WARDEN_LABEL_PROMPT,
200
+ type WardenFixture,
201
+ type WardenLabel,
202
+ type WardenRosterEntry,
203
+ type WardenTriggerKind,
204
+ wardenLabelMessages,
205
+ wardenLabelPrefix,
206
+ } from './feedback/warden-labels';
207
+ export {
208
+ PREFILTER_CONTEXT_CHARS,
209
+ PREFILTER_DESCRIPTION_CHARS,
210
+ PREFILTER_MOMENT_CHARS,
211
+ PREFILTER_SWEEP,
212
+ type PrefilterPair,
213
+ type PrefilterPoint,
214
+ type PrefilterScore,
215
+ type PrefilterTrigger,
216
+ percentiles,
217
+ prefilterConsistency,
218
+ prefilterDescription,
219
+ prefilterQuestion,
220
+ prefilterSkips,
221
+ prefilterState,
222
+ scorePrefilter,
223
+ } from './feedback/warden-prefilter';
224
+ export {
225
+ type CharterScore,
226
+ parseWardenJudgement,
227
+ scoreCharterRun,
228
+ WARDEN_JUDGE_PROMPT,
229
+ type WardenJudgement,
230
+ type WardenProbe,
231
+ type WardenProbeAnswer,
232
+ type WardenProbeTurn,
233
+ wardenJudgeMessage,
234
+ } from './feedback/warden-probes';
187
235
  export {
188
236
  type FlowConfig,
189
237
  type FlowStage,
@@ -297,6 +345,7 @@ export {
297
345
  type ThreadMeta,
298
346
  type ThreadName,
299
347
  type ThreadReference,
348
+ type ThreadSpawn,
300
349
  type ThreadStatus,
301
350
  threadTitle,
302
351
  } from './thread';
package/src/messages.ts CHANGED
@@ -1,4 +1,8 @@
1
- import type { LayerInstanceState, LogicalMessageIdentity } from './context-layer';
1
+ import type {
2
+ CompositionSnapshot,
3
+ LayerInstanceState,
4
+ LogicalMessageIdentity,
5
+ } from './context-layer';
2
6
  import { computeHash } from './context-layer';
3
7
  import type { AssembledContext, PromptBlock } from './context-stack';
4
8
  import type { ServedModel } from './native-evidence';
@@ -27,7 +31,7 @@ export interface InjectionBlock {
27
31
  /** One decorator's recorded participation in a turn. */
28
32
  export interface DecorationParticipant {
29
33
  readonly id: string;
30
- /** contribute | abstain | error | timeout | omitted */
34
+ /** contribute | no-comment | skipped | error | timeout | excluded | omitted */
31
35
  readonly decision: string;
32
36
  readonly reason?: string;
33
37
  /** The three named inputs the participant worked from, never inferred. */
@@ -55,6 +59,8 @@ export type ParticipantRequest =
55
59
  readonly capturedAt: number;
56
60
  /** The model and effort that answered, when the provider answered before the phase sealed. */
57
61
  readonly served?: ServedModel;
62
+ /** What the call's instructions and domain knowledge were composed of; the journal stores version ids for it. */
63
+ readonly composition?: CompositionSnapshot;
58
64
  }
59
65
  | { readonly status: 'not-sent'; readonly phase: string; readonly reason: string }
60
66
  /** A call may have been made, but its input was never observed (e.g. the caller threw first). Not "not-sent". */
@@ -34,6 +34,26 @@ export interface OwnedAdmissionInspection {
34
34
  readonly cleanup: 'not-requested' | 'pending' | 'released' | 'unknown';
35
35
  readonly capacity: 'settled' | 'unknown';
36
36
  }
37
+ /** A native subagent's lifecycle event (agent-session `SubagentLifecycle`, allowlisted). */
38
+ export interface NativeSubagentLifecycle {
39
+ readonly phase: 'started' | 'progress' | 'updated' | 'ended';
40
+ readonly taskId: string;
41
+ readonly toolUseId?: string;
42
+ readonly parentTaskId?: string;
43
+ readonly agentType?: string;
44
+ readonly description?: string;
45
+ readonly depth?: number;
46
+ readonly backgrounded?: boolean;
47
+ readonly status?: string;
48
+ readonly summary?: string;
49
+ readonly prompt?: string;
50
+ readonly usage?: {
51
+ readonly totalTokens: number;
52
+ readonly toolUses?: number;
53
+ readonly durationMs?: number;
54
+ };
55
+ }
56
+
37
57
  export interface NativeEvidence {
38
58
  readonly schema: 1;
39
59
  readonly executionMode?: 'controlled-fixture';
@@ -65,6 +85,10 @@ export interface NativeEvidence {
65
85
  readonly content?: string;
66
86
  readonly correlation?: string;
67
87
  readonly kind?: string;
88
+ /** The parent's tool call that spawned the subagent this came from (absent for the thread's own). */
89
+ readonly parentToolUseId?: string;
90
+ /** A subagent's lifecycle (kind `subagent`), as the runtime reported it. */
91
+ readonly subagent?: NativeSubagentLifecycle;
68
92
  readonly toolName?: string;
69
93
  readonly toolServer?: string;
70
94
  readonly toolMethod?: string;
package/src/thread.ts CHANGED
@@ -44,6 +44,21 @@ export function threadTitle(
44
44
  return meta.description ? { text: meta.description, source: 'description' } : undefined;
45
45
  }
46
46
 
47
+ /** How a subagent thread came to be: who spawned it, through what, and its native identity. */
48
+ export interface ThreadSpawn {
49
+ readonly via: 'foundry' | 'native-claude' | 'native-codex' | 'host-paseo' | 'host-t3';
50
+ /** The parent's tool call that spawned it. */
51
+ readonly toolCallId?: string;
52
+ /** The native run's id (Claude task id, Codex child thread id). */
53
+ readonly nativeTaskId?: string;
54
+ readonly agentType?: string;
55
+ readonly description?: string;
56
+ /** The parent's `handoff` phase row for this spawn. */
57
+ readonly handoffId?: string;
58
+ /** Recorded from the native stream only: no Foundry middleware ran on it. */
59
+ readonly observed?: boolean;
60
+ }
61
+
47
62
  export interface ThreadMeta {
48
63
  /** Living description — what this thread is doing right now. */
49
64
  description: string;
@@ -72,6 +87,15 @@ export interface ThreadMeta {
72
87
  /** Parent thread when this thread is a subagent spawned by another. */
73
88
  parentThreadId?: string;
74
89
 
90
+ /** The parent's turn that spawned this thread (a subagent). */
91
+ parentTurnId?: string;
92
+
93
+ /** How this subagent thread was spawned (docs/reference/SUBAGENTS.md §4.1). */
94
+ spawn?: ThreadSpawn;
95
+
96
+ /** 1 for a child of a main thread. */
97
+ depth?: number;
98
+
75
99
  /** Project that owns this thread. Scopes memory sources and tool calls. */
76
100
  projectId?: string;
77
101