@inixiative/foundry-core 0.5.10 → 0.5.12

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.10",
3
+ "version": "0.5.12",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -33,7 +33,7 @@
33
33
  "bun": ">=1.4.4"
34
34
  },
35
35
  "peerDependencies": {
36
- "@inixiative/archive": "^0.32.0"
36
+ "@inixiative/archive": "^0.33.0"
37
37
  },
38
38
  "peerDependenciesMeta": {
39
39
  "@inixiative/archive": {
@@ -41,6 +41,6 @@
41
41
  }
42
42
  },
43
43
  "devDependencies": {
44
- "@inixiative/archive": "^0.32.0"
44
+ "@inixiative/archive": "^0.33.1"
45
45
  }
46
46
  }
@@ -293,7 +293,13 @@ export function secretInstructionPath(path: string, trusted: readonly string[] =
293
293
  name.startsWith('.') ||
294
294
  /^id_[a-z0-9]+/i.test(name) ||
295
295
  /\.(pem|key|p12|pfx|keystore|jks)$/i.test(name) ||
296
- rest.split(sep).some((part) => CREDENTIAL_DIRECTORIES.has(part))
296
+ rest.split(sep).some((part) => CREDENTIAL_DIRECTORIES.has(part)) ||
297
+ // A `.private` path is never read (FEAT-005): a segment named or ending `.private`, or holding `.private.`.
298
+ path
299
+ .split(sep)
300
+ .some(
301
+ (part) => part === '.private' || part.endsWith('.private') || part.includes('.private.'),
302
+ )
297
303
  );
298
304
  }
299
305
 
@@ -37,7 +37,7 @@ export interface HierarchyMatch {
37
37
  readonly owners?: readonly string[];
38
38
  /** The harness the composed prompt is for. */
39
39
  readonly harness?: readonly string[];
40
- /** The role composed for (e.g. "main", a Warden's agent id). */
40
+ /** The role composed for (e.g. a thread role, a Warden's agent id), or one of its base roles. */
41
41
  readonly roles?: readonly string[];
42
42
  /** The slot composed (e.g. "instructions", "domainKnowledge"). */
43
43
  readonly slots?: readonly string[];
@@ -124,6 +124,11 @@ export interface HierarchyLocation {
124
124
  readonly harnessLoads?: readonly string[];
125
125
  /** The role composed for; also `{role}` in paths. */
126
126
  readonly role?: string;
127
+ /**
128
+ * Roles whose files come first: a source whose path names `{role}` is read once per base role, in
129
+ * order, then for `role` (e.g. a shared base role under each thread role).
130
+ */
131
+ readonly baseRoles?: readonly string[];
127
132
  /** The slot composed; also `{slot}` in paths. */
128
133
  readonly slot?: string;
129
134
  /**
@@ -221,7 +226,14 @@ function matches(when: HierarchyMatch | undefined, location: HierarchyLocation):
221
226
  if (when.projects && !(location.projectId && when.projects.includes(location.projectId)))
222
227
  return false;
223
228
  if (when.owners && !(location.owner && when.owners.includes(location.owner))) return false;
224
- if (when.roles && !(location.role && when.roles.includes(location.role))) return false;
229
+ // A base role counts as the role it sits under (a node for `main` serves every thread role).
230
+ if (
231
+ when.roles &&
232
+ ![...(location.baseRoles ?? []), ...(location.role ? [location.role] : [])].some((role) =>
233
+ when.roles!.includes(role),
234
+ )
235
+ )
236
+ return false;
225
237
  if (when.slots && !(location.slot && when.slots.includes(location.slot))) return false;
226
238
  if (when.paths && !matchedPath(when.paths, location)) return false;
227
239
  return true;
@@ -480,6 +492,22 @@ export async function discoverHierarchy(
480
492
  ) => {
481
493
  if (!matches(source.when, location) || (source.id && loadedNatively.has(source.id))) return;
482
494
  if (!slotted && !source.when?.slots && !unslottedApplies(entry.node, location)) return;
495
+ const templates = typeof source.path === 'string' ? [source.path] : source.path;
496
+ const roles =
497
+ location.baseRoles?.length && templates.some((path) => path.includes('{role}'))
498
+ ? [...new Set([...location.baseRoles, ...(location.role ? [location.role] : [])])]
499
+ : undefined;
500
+ for (const pass of roles ? roles.map((role) => ({ ...vars, role })) : [vars])
501
+ await collect(entry, source, position, base, pass);
502
+ };
503
+
504
+ const collect = async (
505
+ entry: SelectedHierarchyNode,
506
+ source: HierarchyFileSource,
507
+ position: number,
508
+ base: string | undefined,
509
+ vars: Readonly<Record<string, string | undefined>>,
510
+ ) => {
483
511
  const place = (path: string) => (isAbsolute(path) ? path : base ? join(base, path) : undefined);
484
512
  const literal = (template: string) => {
485
513
  const expanded = expandHierarchyPath(template, vars, location.home);
@@ -42,7 +42,31 @@ export type StreamEvent =
42
42
  message: string;
43
43
  severity: 'error' | 'warn';
44
44
  timestamp: number;
45
- };
45
+ }
46
+ | ({ kind: 'activity'; timestamp: number } & SystemActivity);
47
+
48
+ export type ActivityEvent = Extract<StreamEvent, { kind: 'activity' }>;
49
+
50
+ /**
51
+ * What the system is doing around a thread, not in it (Aron, 2026-11-11): a project's Wardens
52
+ * starting, a decision process restarting, a rate limit. The viewer keeps the latest per `key`
53
+ * and shows it above the composer; `error` events stay for what failed and needs the person.
54
+ */
55
+ export interface SystemActivity {
56
+ /** One thing that changes over time ("project-wardens:<projectId>"); a later event replaces it. */
57
+ readonly key: string;
58
+ readonly source: string;
59
+ /** The project it concerns; absent when it touches every thread (a login's rate limit). */
60
+ readonly projectId?: string;
61
+ /** `idle` is kept for the log only; the others show, in the status colours. */
62
+ readonly state: 'working' | 'ready' | 'degraded' | 'failed' | 'idle';
63
+ /** Shown until then unless a later event of the same key replaces it (a rate limit's end). */
64
+ readonly until?: number;
65
+ /** One plain sentence for the person. */
66
+ readonly summary: string;
67
+ /** The technical line, for the expanded log. */
68
+ readonly detail: string;
69
+ }
46
70
 
47
71
  /**
48
72
  * Unified event stream across all threads and sessions.
@@ -56,6 +80,8 @@ export type StreamEvent =
56
80
  export class EventStream {
57
81
  private _listeners: Array<(event: StreamEvent) => void> = [];
58
82
  private _history: StreamEvent[] = [];
83
+ /** The latest activity of each key, kept past the history bound: a failure stays until it clears. */
84
+ private _activities = new Map<string, ActivityEvent>();
59
85
  private _maxHistory: number;
60
86
 
61
87
  constructor(maxHistory: number = 5000) {
@@ -64,6 +90,10 @@ export class EventStream {
64
90
 
65
91
  /** Push an event into the stream. */
66
92
  push(event: StreamEvent): void {
93
+ if (event.kind === 'activity') {
94
+ this._activities.delete(event.key);
95
+ this._activities.set(event.key, event);
96
+ }
67
97
  this._history.push(event);
68
98
  if (this._history.length > this._maxHistory) {
69
99
  this._history.shift();
@@ -117,8 +147,19 @@ export class EventStream {
117
147
  this.push({ kind: 'error', source, message, severity, timestamp: Date.now() });
118
148
  }
119
149
 
150
+ /** Push what the system is doing (shown above the composer, never as a toast). */
151
+ pushActivity(activity: SystemActivity): void {
152
+ this.push({ kind: 'activity', ...activity, timestamp: Date.now() });
153
+ }
154
+
155
+ /** The latest activity of every key, oldest first. */
156
+ activities(): ActivityEvent[] {
157
+ return [...this._activities.values()];
158
+ }
159
+
120
160
  /** Clear history. */
121
161
  clear(): void {
122
162
  this._history = [];
163
+ this._activities.clear();
123
164
  }
124
165
  }
@@ -221,7 +221,11 @@ export function extractCorrections(
221
221
  return { corrections, secretsScrubbed };
222
222
  }
223
223
 
224
- const SECRET_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
224
+ /**
225
+ * Credentials by their own shape (a provider's token prefix, a key block): specific enough that a
226
+ * match is a secret. Foundry lifts these from a message even when the person forgot `$$` (FEAT-005).
227
+ */
228
+ const SHAPED_SECRETS: ReadonlyArray<readonly [RegExp, string]> = [
225
229
  [/sk-ant-[A-Za-z0-9_-]{20,}/g, '[REDACTED:anthropic-key]'],
226
230
  [/sk-[A-Za-z0-9_-]{20,}/g, '[REDACTED:api-key]'],
227
231
  [/gh[pousr]_[A-Za-z0-9]{20,}/g, '[REDACTED:github-token]'],
@@ -245,6 +249,10 @@ const SECRET_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
245
249
  ],
246
250
  [/hooks\.slack\.com\/services\/[A-Za-z0-9/]+/g, '[REDACTED:slack-webhook]'],
247
251
  [/\bASIA[A-Z0-9]{16}/g, '[REDACTED:aws-key]'],
252
+ ];
253
+
254
+ /** Credentials by context (an assignment, a header, a URL): a match may be a secret, so it is scrubbed but never lifted. */
255
+ const CONTEXT_SECRETS: ReadonlyArray<readonly [RegExp, string]> = [
248
256
  [/\b(Bearer|Basic|DPoP)\s+[A-Za-z0-9._~+/=-]{16,}/g, '$1 [REDACTED:credential]'],
249
257
  [/\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]+@/gi, '$1[REDACTED:password]@'],
250
258
  [
@@ -257,6 +265,32 @@ const SECRET_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
257
265
  ],
258
266
  ];
259
267
 
268
+ const SECRET_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
269
+ ...SHAPED_SECRETS,
270
+ ...CONTEXT_SECRETS,
271
+ ];
272
+
273
+ /**
274
+ * The shaped credentials safe to lift from a message the person forgot to mark: each never starts
275
+ * inside a word (`task-runner-…` holds `sk-…`), and the two loosest shapes (`sk-…` and
276
+ * `<word>_live_<…>`, which long hyphenated or snake_case names also fit) must carry a digit. Fresh
277
+ * regex objects, so lifting never shares match state with `scrubSecrets`. Scrubbing a false match is
278
+ * harmless; lifting one would take words out of the person's message.
279
+ */
280
+ export const SHAPED_SECRET_PATTERNS: readonly RegExp[] = SHAPED_SECRETS.map(([pattern]) => {
281
+ const loose =
282
+ pattern.source.startsWith('sk-[') || pattern.source.includes('(?:live|test|prod|runtime)');
283
+ const digit = loose ? '(?=[A-Za-z0-9_-]*\\d)' : '';
284
+ return new RegExp(`(?<![A-Za-z0-9_-])${digit}(?:${pattern.source})`, pattern.flags);
285
+ });
286
+
287
+ /** How many credentials known only by context (`PASSWORD=…`, `Bearer …`, a URL password) the text holds. */
288
+ export function contextSecretHits(text: string): number {
289
+ let hits = 0;
290
+ for (const [pattern] of CONTEXT_SECRETS) hits += [...text.matchAll(pattern)].length;
291
+ return hits;
292
+ }
293
+
260
294
  /** Replace credential-shaped substrings. Transcripts are exactly where leaked keys live. */
261
295
  export function scrubSecrets(text: string): { text: string; hits: number } {
262
296
  let hits = 0;
package/src/index.ts CHANGED
@@ -166,7 +166,13 @@ export {
166
166
  verifyExpertDelivery,
167
167
  } from './delivery-evidence';
168
168
  // Observability
169
- export { EventStream, type SessionEvent, type StreamEvent } from './event-stream';
169
+ export {
170
+ type ActivityEvent,
171
+ EventStream,
172
+ type SessionEvent,
173
+ type StreamEvent,
174
+ type SystemActivity,
175
+ } from './event-stream';
170
176
  export {
171
177
  type ExecuteHandler,
172
178
  type ExecuteMeta,
@@ -186,6 +192,7 @@ export {
186
192
  type CorrectionDetection,
187
193
  type CorrectionEvent,
188
194
  type CorrectionSignal,
195
+ contextSecretHits,
189
196
  correctionClassifyMessages,
190
197
  correctionId,
191
198
  detectCorrection,
@@ -194,6 +201,7 @@ export {
194
201
  type FeedbackTag,
195
202
  parseCorrectionClassifications,
196
203
  proseOf,
204
+ SHAPED_SECRET_PATTERNS,
197
205
  scrubSecrets,
198
206
  type TranscriptTurn,
199
207
  } from './feedback/correction';
@@ -423,7 +431,6 @@ export {
423
431
  type ThreadPlan,
424
432
  type ThreadPlanItem,
425
433
  type ThreadReference,
426
- type ThreadSetup,
427
434
  type ThreadSnooze,
428
435
  type ThreadSpawn,
429
436
  type ThreadStatus,
@@ -245,7 +245,7 @@ export interface ModelSweepOption {
245
245
  label: string;
246
246
  }
247
247
 
248
- export const MODEL_REGISTRY_UPDATED_AT = '2026-09-24';
248
+ export const MODEL_REGISTRY_UPDATED_AT = '2026-11-11';
249
249
 
250
250
  /**
251
251
  * Decision default: Claude Haiku 5.5, the cheapest current Claude model tagged `judgment`. Held here
@@ -292,7 +292,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
292
292
  models: [
293
293
  {
294
294
  id: 'fable',
295
- label: 'Fable 5.1',
295
+ label: 'Fable 5.2',
296
296
  tier: 'powerful',
297
297
  costTier: 'high',
298
298
  contextWindow: 1_000_000,
@@ -301,7 +301,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
301
301
  nativeAlias: true,
302
302
  capabilities: POWERFUL_REASONING,
303
303
  reasoning: CLAUDE_REASONING,
304
- notes: 'Claude Code alias for claude-fable-5-1.',
304
+ notes: 'Claude Code alias for claude-fable-5-2.',
305
305
  },
306
306
  {
307
307
  id: 'opus',
@@ -348,6 +348,17 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
348
348
  capabilities: POWERFUL_REASONING,
349
349
  reasoning: CLAUDE_REASONING,
350
350
  },
351
+ {
352
+ id: 'claude-fable-5-2',
353
+ label: 'Fable 5.2',
354
+ tier: 'powerful',
355
+ costTier: 'high',
356
+ contextWindow: 1_000_000,
357
+ maxOutputTokens: 128_000,
358
+ runtimeKind: 'native-harness',
359
+ capabilities: POWERFUL_REASONING,
360
+ reasoning: CLAUDE_REASONING,
361
+ },
351
362
  {
352
363
  id: 'claude-fable-5-1',
353
364
  label: 'Fable 5.1',
@@ -382,6 +393,18 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
382
393
  credential: 'api-key',
383
394
  enabledByDefault: true,
384
395
  models: [
396
+ {
397
+ id: 'claude-fable-5-2',
398
+ label: 'Claude Fable 5.2',
399
+ tier: 'powerful',
400
+ costTier: 'high',
401
+ contextWindow: 1_000_000,
402
+ maxOutputTokens: 128_000,
403
+ runtimeKind: 'api',
404
+ capabilities: POWERFUL_REASONING,
405
+ reasoning: CLAUDE_REASONING,
406
+ notes: 'Thinking is always on; steer with effort.',
407
+ },
385
408
  {
386
409
  id: 'claude-fable-5-1',
387
410
  label: 'Claude Fable 5.1',
@@ -416,6 +439,17 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
416
439
  capabilities: POWERFUL_REASONING,
417
440
  reasoning: CLAUDE_REASONING,
418
441
  },
442
+ {
443
+ id: 'claude-sonnet-5-5',
444
+ label: 'Claude Sonnet 5.5',
445
+ tier: 'standard',
446
+ costTier: 'medium',
447
+ contextWindow: 1_000_000,
448
+ maxOutputTokens: 128_000,
449
+ runtimeKind: 'api',
450
+ capabilities: STANDARD_REASONING,
451
+ reasoning: CLAUDE_REASONING,
452
+ },
419
453
  {
420
454
  id: 'claude-sonnet-5',
421
455
  label: 'Claude Sonnet 5',
package/src/scope.ts CHANGED
@@ -16,6 +16,10 @@ export interface OwnershipScope {
16
16
  readonly projectId?: string;
17
17
  /** Where the thread's native session runs. Not ownership: sources that read the workspace resolve against it. */
18
18
  readonly cwd?: string;
19
+ /** The thread role it composes as; absent: its worker's default. Not ownership. */
20
+ readonly role?: string;
21
+ /** The worker running its main session. Not ownership. */
22
+ readonly worker?: string;
19
23
  }
20
24
 
21
25
  /** Copy a scope, dropping undefined fields so equality checks are stable. */
package/src/thread.ts CHANGED
@@ -276,18 +276,15 @@ export interface ThreadMeta {
276
276
  /** 1 for a child of a main thread. */
277
277
  depth?: number;
278
278
 
279
- /**
280
- * Start-up of a thread whose Wardens are held in its own process (docs/THREAD-TREE.md spec C):
281
- * turns typed meanwhile wait as `queued` until it is `ready` (or `degraded`).
282
- */
283
- setup?: ThreadSetup;
284
-
285
279
  /** Project that owns this thread. Scopes memory sources and tool calls. */
286
280
  projectId?: string;
287
281
 
288
282
  /** The harness running this thread's main session (`claude-code`, `codex`). Set at creation; never changed silently. */
289
283
  worker?: string;
290
284
 
285
+ /** The thread role its instructions compose as (`coder`, `assistant`, …). Absent: its worker's default role. */
286
+ role?: string;
287
+
291
288
  /** When this thread was created. */
292
289
  readonly createdAt: number;
293
290
 
@@ -298,15 +295,6 @@ export interface ThreadMeta {
298
295
  archivedAt?: number;
299
296
  }
300
297
 
301
- export interface ThreadSetup {
302
- status: 'starting' | 'ready' | 'degraded';
303
- startedAt: number;
304
- readyAt?: number;
305
- /** Every configured Warden, those holding a spare, and those that did not by the deadline. */
306
- wardens: { expected: number; held: number; failed: string[] };
307
- runtime: 'claude-in-session' | 'codex-in-process' | 'side-session';
308
- }
309
-
310
298
  export interface Dispatch<T = unknown> {
311
299
  readonly agentId: string;
312
300
  readonly timestamp: number;
@@ -403,6 +391,8 @@ export interface ThreadConfig {
403
391
  projectId?: string;
404
392
  /** The harness running this thread's main session. */
405
393
  worker?: string;
394
+ /** The thread role its instructions compose as. */
395
+ role?: string;
406
396
  /** Optional token tracker — auto-records costs for every dispatch. */
407
397
  tokenTracker?: TokenTracker;
408
398
  }
@@ -456,6 +446,7 @@ export class Thread {
456
446
  ...(opts?.forkedFrom ? { forkedFrom: opts.forkedFrom } : {}),
457
447
  projectId: opts?.projectId,
458
448
  ...(opts?.worker ? { worker: opts.worker } : {}),
449
+ ...(opts?.role ? { role: opts.role } : {}),
459
450
  createdAt: now,
460
451
  lastActiveAt: now,
461
452
  };
@@ -219,18 +219,19 @@ export interface UsageSummary {
219
219
 
220
220
  // ---------------------------------------------------------------------------
221
221
  // Default cost table. Anthropic: first-party API list prices per 1M tokens, as published at
222
- // https://platform.claude.com/docs/en/about-claude/pricing (read 2026-11-01; cache writes are the
222
+ // https://platform.claude.com/docs/en/about-claude/pricing (read 2026-11-11; cache writes are the
223
223
  // 5-minute rate, 1.25x input). Haiku 5.5 is its rate up to 100K-token prompts.
224
224
  // ---------------------------------------------------------------------------
225
225
 
226
226
  export const DEFAULT_COST_TABLE: CostTable = {
227
227
  anthropic: {
228
+ 'claude-fable-5-2': { inputPer1M: 8, outputPer1M: 40, cacheReadPer1M: 0.2 },
228
229
  'claude-fable-5-1': { inputPer1M: 10, outputPer1M: 50, cacheReadPer1M: 0.25 },
229
230
  'claude-fable-5': { inputPer1M: 10, outputPer1M: 50 },
230
231
  'claude-opus-5-5': { inputPer1M: 4, outputPer1M: 20, cacheReadPer1M: 0.2 },
231
232
  'claude-opus-5': { inputPer1M: 5, outputPer1M: 25 },
232
233
  'claude-opus-4-8': { inputPer1M: 5, outputPer1M: 25 },
233
- 'claude-sonnet-5-5': { inputPer1M: 2, outputPer1M: 10, cacheReadPer1M: 0.2 },
234
+ 'claude-sonnet-5-5': { inputPer1M: 2, outputPer1M: 10, cacheReadPer1M: 0.1 },
234
235
  'claude-sonnet-5': { inputPer1M: 2, outputPer1M: 10 },
235
236
  'claude-haiku-5-5': { inputPer1M: 0.1, outputPer1M: 0.5 },
236
237
  'claude-haiku-4-5': { inputPer1M: 1, outputPer1M: 5 },
package/src/types.ts CHANGED
@@ -217,8 +217,6 @@ export interface LLMProvider {
217
217
  * `threadPrefix` starts at once. `messages` end at those prefixes; no decision is made. Optional.
218
218
  */
219
219
  prestart?(messages: LLMMessage[], opts: CompletionOpts): void;
220
- /** A thread closed: release whatever the provider holds for it. Optional. */
221
- releaseThread?(threadId: string): Promise<void>;
222
220
  }
223
221
 
224
222
  export interface EmbeddingProvider {