@inixiative/foundry-core 0.1.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 +21 -0
- package/package.json +39 -0
- package/src/action-prompt.ts +248 -0
- package/src/adapters/file-memory.ts +718 -0
- package/src/adapters/http-memory.ts +141 -0
- package/src/adapters/index.ts +4 -0
- package/src/adapters/markdown-docs.ts +213 -0
- package/src/adapters/sqlite-memory.ts +188 -0
- package/src/base-agent.ts +137 -0
- package/src/bounded-set.ts +42 -0
- package/src/cache-lifecycle.ts +187 -0
- package/src/capability.ts +252 -0
- package/src/classifier.ts +43 -0
- package/src/context-layer.ts +706 -0
- package/src/context-stack.ts +223 -0
- package/src/credentials.ts +14 -0
- package/src/decider.ts +62 -0
- package/src/delivery-evidence.ts +85 -0
- package/src/event-stream.ts +105 -0
- package/src/executor.ts +185 -0
- package/src/harness.ts +749 -0
- package/src/hooks.ts +133 -0
- package/src/hydrator.ts +148 -0
- package/src/id.ts +39 -0
- package/src/index.ts +273 -0
- package/src/intervention.ts +134 -0
- package/src/messages.ts +315 -0
- package/src/middleware.ts +118 -0
- package/src/native-evidence.ts +140 -0
- package/src/permission-middleware.ts +92 -0
- package/src/retry.ts +91 -0
- package/src/router.ts +41 -0
- package/src/scope.ts +20 -0
- package/src/signal.ts +117 -0
- package/src/thread.ts +528 -0
- package/src/token-counts.ts +30 -0
- package/src/token-tracker.ts +433 -0
- package/src/tools.ts +714 -0
- package/src/trace.ts +261 -0
- package/src/types.ts +167 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/** Public, owned projection. Raw protocol and reasoning are deliberately absent. */
|
|
2
|
+
export interface NativeOwner {
|
|
3
|
+
readonly threadId: string;
|
|
4
|
+
readonly projectId?: string;
|
|
5
|
+
readonly generation: string;
|
|
6
|
+
readonly messageId?: string;
|
|
7
|
+
readonly dispatchId: string;
|
|
8
|
+
readonly reviewJobId?: string;
|
|
9
|
+
/** Foundry's provider pool key, distinct from every native identity. */
|
|
10
|
+
readonly providerSessionKey?: string;
|
|
11
|
+
}
|
|
12
|
+
/** Compare typed ownership fields, not serialization order or prompt text. */
|
|
13
|
+
export function sameNativeOwner(a: NativeOwner | undefined, b: NativeOwner | undefined): boolean {
|
|
14
|
+
return !!a && !!b && (["threadId", "projectId", "generation", "messageId", "dispatchId", "reviewJobId", "providerSessionKey"] as const)
|
|
15
|
+
.every(key => a[key] === b[key]);
|
|
16
|
+
}
|
|
17
|
+
export interface OwnedAdmissionInspection {
|
|
18
|
+
readonly evidence: NativeEvidence;
|
|
19
|
+
/** Physical call settlement is independent of a native terminal and local waiter. */
|
|
20
|
+
readonly call: "settled" | "pending" | "unknown";
|
|
21
|
+
readonly cleanup: "not-requested" | "pending" | "released" | "unknown";
|
|
22
|
+
readonly capacity: "settled" | "unknown";
|
|
23
|
+
}
|
|
24
|
+
export interface NativeEvidence {
|
|
25
|
+
readonly schema: 1;
|
|
26
|
+
readonly executionMode?: "controlled-fixture";
|
|
27
|
+
readonly owner?: NativeOwner;
|
|
28
|
+
readonly admissionId?: string;
|
|
29
|
+
readonly nativeSessionId?: string;
|
|
30
|
+
readonly externalSessionId?: string;
|
|
31
|
+
readonly threadId?: string;
|
|
32
|
+
readonly turnId?: string;
|
|
33
|
+
readonly itemId?: string;
|
|
34
|
+
readonly callId?: string;
|
|
35
|
+
readonly rpcRequestId?: string | number;
|
|
36
|
+
readonly nativeOutcome: "unknown" | "completed" | "failed";
|
|
37
|
+
readonly localOutcome?: "pending" | "resolved" | "rejected";
|
|
38
|
+
readonly dispatch?: "not-dispatched" | "attempted";
|
|
39
|
+
readonly transportOutcome?: "open" | "failed" | "closed";
|
|
40
|
+
readonly rpcOutcome?: "pending" | "resolved" | "failed" | "unknown";
|
|
41
|
+
readonly localFailure?: string;
|
|
42
|
+
readonly localError?: string;
|
|
43
|
+
readonly observedAt?: number;
|
|
44
|
+
readonly terminal?: Readonly<{ type: string; eventId?: string; turnId?: string; subtype?: string; reason?: string; apiErrorStatus?: number }>;
|
|
45
|
+
readonly content?: string;
|
|
46
|
+
readonly correlation?: string;
|
|
47
|
+
readonly kind?: string;
|
|
48
|
+
readonly toolName?: string;
|
|
49
|
+
readonly toolServer?: string;
|
|
50
|
+
readonly toolMethod?: string;
|
|
51
|
+
readonly toolInput?: Readonly<Record<string, unknown>>;
|
|
52
|
+
readonly toolInputOmitted?: string;
|
|
53
|
+
readonly toolOutput?: string;
|
|
54
|
+
readonly toolOutputOmitted?: boolean;
|
|
55
|
+
/** tool_result only: exact public tool names from tool-search reference blocks, in order,
|
|
56
|
+
* duplicates preserved, copied and frozen. Never a retrieval success by itself. */
|
|
57
|
+
readonly toolReferences?: readonly string[];
|
|
58
|
+
/** tool_result only: public allowlisted labels of omitted result blocks; unknown shapes are
|
|
59
|
+
* "unsupported". Present only with toolOutputOmitted. */
|
|
60
|
+
readonly toolOutputOmittedTypes?: readonly string[];
|
|
61
|
+
readonly toolError?: boolean;
|
|
62
|
+
readonly observationFailures?: number;
|
|
63
|
+
readonly configuration?: Readonly<{ requestedModel: string; observedModel?: string; requestedMaxTurns?: number | null;
|
|
64
|
+
engine?: "mcp" | "app-server"; transportMode?: "controlled-fixture"; requestedEffort?: string; observedEffort?: string | null;
|
|
65
|
+
history?: Readonly<{source:"thread/start"|"thread/resume";available:boolean;hasMore:boolean;turns:readonly Readonly<{id:string;status:string}>[]}>;
|
|
66
|
+
turnBudgetEnforcement: "launch-option" | "unavailable"; tokenBudget: "unavailable"; effortBudget: "unavailable" }>;
|
|
67
|
+
readonly text?: string;
|
|
68
|
+
/** Public text form only; reasoning is never projected. Missing phase is not a final-answer acknowledgment. */
|
|
69
|
+
readonly textKind?: "delta" | "snapshot";
|
|
70
|
+
readonly textPhase?: "commentary" | "final_answer";
|
|
71
|
+
/** Safe setup/status evidence, never launch paths or config values. */
|
|
72
|
+
readonly runtimeStatus?: Readonly<{ type: string; status?: string; reason?: string; server?: string; tools?: readonly string[] }>;
|
|
73
|
+
readonly bridge?: { readonly id: string; readonly configurationHash: string; readonly toolPolicy?: NativeToolPolicy; readonly tools: readonly NativeToolEvidence[] };
|
|
74
|
+
}
|
|
75
|
+
export interface NativeToolRecord {
|
|
76
|
+
readonly id: string;
|
|
77
|
+
readonly bridgeId: string;
|
|
78
|
+
readonly operation: string;
|
|
79
|
+
readonly owner: { readonly threadId: string; readonly projectId?: string; readonly generation: string };
|
|
80
|
+
readonly association: { readonly kind: "registered-admission-window" | "unassociated"; readonly admissionId?: string; readonly owner?: NativeOwner };
|
|
81
|
+
readonly sdkRequestId?: string | number;
|
|
82
|
+
readonly sdkSessionId?: string;
|
|
83
|
+
readonly nativeCorrelation: "unknown";
|
|
84
|
+
readonly startedAt: number;
|
|
85
|
+
readonly finishedAt: number;
|
|
86
|
+
readonly arguments: Readonly<Record<string, unknown>>;
|
|
87
|
+
readonly result: string;
|
|
88
|
+
readonly digest: string;
|
|
89
|
+
readonly status: string;
|
|
90
|
+
readonly refusal?: string;
|
|
91
|
+
}
|
|
92
|
+
export interface NativeToolEvidence {
|
|
93
|
+
readonly record: NativeToolRecord;
|
|
94
|
+
readonly persistence: "pending" | "committed" | "failed";
|
|
95
|
+
readonly publication: "pending" | "published" | "reconciliation-needed";
|
|
96
|
+
readonly error?: string;
|
|
97
|
+
}
|
|
98
|
+
/** Process lifetime grant. Data/functions never become part of a provider prompt. */
|
|
99
|
+
export interface NativeBridgeSource {
|
|
100
|
+
readonly key: string;
|
|
101
|
+
readonly toolPolicy?: NativeToolPolicy;
|
|
102
|
+
check(owner: NativeOwner): void;
|
|
103
|
+
acquire(): Promise<NativeBridgeLease>;
|
|
104
|
+
}
|
|
105
|
+
export interface NativeBridgeLease {
|
|
106
|
+
readonly id: string;
|
|
107
|
+
readonly name: string;
|
|
108
|
+
readonly owner: { readonly threadId: string; readonly projectId?: string; readonly generation: string };
|
|
109
|
+
readonly configurationHash: string;
|
|
110
|
+
readonly toolPolicy?: NativeToolPolicy;
|
|
111
|
+
/** Private, empty native cwd owned by a restricted fixture lease. */
|
|
112
|
+
readonly fixtureCwd?: string;
|
|
113
|
+
readonly launch: { readonly claudeJson: string; readonly codexOverrides: readonly string[] };
|
|
114
|
+
check(): void;
|
|
115
|
+
register(evidence: NativeEvidence): void;
|
|
116
|
+
observe(evidence: NativeEvidence): void;
|
|
117
|
+
evidence(admissionId?: string): readonly NativeToolEvidence[];
|
|
118
|
+
status?(): Readonly<{ closed: boolean; pendingCleanups: number; cleanupFailures: number; evictedRecords: number }>;
|
|
119
|
+
close(): Promise<void>;
|
|
120
|
+
}
|
|
121
|
+
/** Immutable policy identity; contains no credentials, launch paths or tool input. */
|
|
122
|
+
export interface NativeToolPolicy {
|
|
123
|
+
readonly version: "isolated-fixture-v1";
|
|
124
|
+
readonly digest: string;
|
|
125
|
+
}
|
|
126
|
+
export interface NativeObservation {
|
|
127
|
+
readonly owner: NativeOwner;
|
|
128
|
+
readonly bridge?: NativeBridgeSource;
|
|
129
|
+
/** Durable capacity check before constructing/resuming a process. Registration still rechecks atomically. */
|
|
130
|
+
preflight?(owner: NativeOwner): void | Promise<void>;
|
|
131
|
+
/** Required registration. A failed write here must prevent native admission. */
|
|
132
|
+
register(evidence: NativeEvidence): void | Promise<void>;
|
|
133
|
+
/** Fallible observer; never vetoes executor completion. */
|
|
134
|
+
observe(evidence: NativeEvidence): void | Promise<void>;
|
|
135
|
+
}
|
|
136
|
+
export function freezeEvidence<T>(value: T): T {
|
|
137
|
+
const copy = structuredClone(value);
|
|
138
|
+
const freeze = (v: unknown) => { if (v && typeof v === "object" && !Object.isFrozen(v)) { for (const nested of Object.values(v)) freeze(nested); Object.freeze(v); } };
|
|
139
|
+
freeze(copy); return copy;
|
|
140
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Middleware, DispatchContext, MiddlewareNext } from "./middleware";
|
|
2
|
+
import {
|
|
3
|
+
CapabilityGate,
|
|
4
|
+
CapabilityDeniedError,
|
|
5
|
+
type Capability,
|
|
6
|
+
} from "./capability";
|
|
7
|
+
|
|
8
|
+
export interface PermissionCheckResult {
|
|
9
|
+
action: "allow" | "deny" | "ask";
|
|
10
|
+
/** Optional modified payload (like Claude Code's updatedInput). */
|
|
11
|
+
modifiedPayload?: unknown;
|
|
12
|
+
/** Reason for denial. */
|
|
13
|
+
reason?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PermissionCheck {
|
|
17
|
+
/** Which capability this check relates to. */
|
|
18
|
+
capability: Capability;
|
|
19
|
+
/** Narrow the check to specific agents. If omitted, applies to all. */
|
|
20
|
+
agentIds?: string[];
|
|
21
|
+
/** Custom check logic beyond the gate. */
|
|
22
|
+
check: (ctx: DispatchContext) => PermissionCheckResult | Promise<PermissionCheckResult>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PermissionMiddlewareConfig {
|
|
26
|
+
/** The capability gate to check against. */
|
|
27
|
+
gate: CapabilityGate;
|
|
28
|
+
/** Thread ID for prompt context. */
|
|
29
|
+
threadId: string;
|
|
30
|
+
/** Default capability to check for all dispatches. Default: "llm:call". */
|
|
31
|
+
defaultCapability?: Capability;
|
|
32
|
+
/** Additional custom permission checks. */
|
|
33
|
+
checks?: PermissionCheck[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates a middleware that checks permissions before dispatch.
|
|
38
|
+
*
|
|
39
|
+
* Integrates the existing CapabilityGate into the dispatch pipeline.
|
|
40
|
+
* Records permission decisions in ctx.annotations.
|
|
41
|
+
*/
|
|
42
|
+
export function permissionMiddleware(config: PermissionMiddlewareConfig): Middleware {
|
|
43
|
+
const {
|
|
44
|
+
gate,
|
|
45
|
+
threadId,
|
|
46
|
+
defaultCapability = "llm:call",
|
|
47
|
+
checks,
|
|
48
|
+
} = config;
|
|
49
|
+
|
|
50
|
+
return async (ctx: DispatchContext, next: MiddlewareNext) => {
|
|
51
|
+
// Check default capability via gate
|
|
52
|
+
const resolution = await gate.check(defaultCapability, {
|
|
53
|
+
agentId: ctx.agentId,
|
|
54
|
+
threadId,
|
|
55
|
+
detail: `Dispatch to agent ${ctx.agentId}`,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (resolution.action === "rejected") {
|
|
59
|
+
ctx.annotations["permission:denied"] = true;
|
|
60
|
+
ctx.annotations["permission:capability"] = defaultCapability;
|
|
61
|
+
throw new CapabilityDeniedError(defaultCapability, "deny");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
ctx.annotations["permission:approved"] = true;
|
|
65
|
+
ctx.annotations["permission:by"] = resolution.by;
|
|
66
|
+
|
|
67
|
+
// Run custom checks
|
|
68
|
+
if (checks) {
|
|
69
|
+
for (const check of checks) {
|
|
70
|
+
if (check.agentIds && !check.agentIds.includes(ctx.agentId)) continue;
|
|
71
|
+
|
|
72
|
+
const result = await check.check(ctx);
|
|
73
|
+
|
|
74
|
+
if (result.action === "deny") {
|
|
75
|
+
ctx.annotations["permission:denied"] = true;
|
|
76
|
+
ctx.annotations["permission:capability"] = check.capability;
|
|
77
|
+
ctx.annotations["permission:reason"] = result.reason;
|
|
78
|
+
throw new CapabilityDeniedError(
|
|
79
|
+
check.capability,
|
|
80
|
+
"deny",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (result.modifiedPayload !== undefined) {
|
|
85
|
+
ctx.annotations["permission:modifiedPayload"] = result.modifiedPayload;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return next();
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Middleware, DispatchContext, MiddlewareNext } from "./middleware";
|
|
2
|
+
import { CapabilityDeniedError } from "./capability";
|
|
3
|
+
import { BudgetExceededError } from "./token-tracker";
|
|
4
|
+
|
|
5
|
+
export interface RetryConfig {
|
|
6
|
+
/** Maximum retry attempts. Default: 3. */
|
|
7
|
+
maxRetries?: number;
|
|
8
|
+
/** Initial delay in ms before first retry. Default: 1000. */
|
|
9
|
+
initialDelayMs?: number;
|
|
10
|
+
/** Maximum delay cap in ms. Default: 30_000. */
|
|
11
|
+
maxDelayMs?: number;
|
|
12
|
+
/** Backoff multiplier. Default: 2. */
|
|
13
|
+
backoffFactor?: number;
|
|
14
|
+
/** Add jitter to prevent thundering herd. Default: true. */
|
|
15
|
+
jitter?: boolean;
|
|
16
|
+
/** Give-up threshold in ms (total elapsed). Default: 120_000. */
|
|
17
|
+
giveUpAfterMs?: number;
|
|
18
|
+
/** Predicate: which errors should be retried. Default: all except denied/budget. */
|
|
19
|
+
isRetryable?: (error: unknown) => boolean;
|
|
20
|
+
/** Called on each retry attempt (for logging/observability). */
|
|
21
|
+
onRetry?: (attempt: number, error: unknown, delayMs: number) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function defaultIsRetryable(error: unknown): boolean {
|
|
25
|
+
if (error instanceof CapabilityDeniedError) return false;
|
|
26
|
+
if (error instanceof BudgetExceededError) return false;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sleep(ms: number): Promise<void> {
|
|
31
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a middleware that wraps dispatch with exponential backoff retry.
|
|
36
|
+
*
|
|
37
|
+
* Composable: register globally or per-agent via `middleware.useWhen()`.
|
|
38
|
+
*/
|
|
39
|
+
export function retryMiddleware(config: RetryConfig = {}): Middleware {
|
|
40
|
+
const {
|
|
41
|
+
maxRetries = 3,
|
|
42
|
+
initialDelayMs = 1000,
|
|
43
|
+
maxDelayMs = 30_000,
|
|
44
|
+
backoffFactor = 2,
|
|
45
|
+
jitter = true,
|
|
46
|
+
giveUpAfterMs = 120_000,
|
|
47
|
+
isRetryable = defaultIsRetryable,
|
|
48
|
+
onRetry,
|
|
49
|
+
} = config;
|
|
50
|
+
|
|
51
|
+
return async (ctx: DispatchContext, next: MiddlewareNext) => {
|
|
52
|
+
const startTime = Date.now();
|
|
53
|
+
let lastError: unknown;
|
|
54
|
+
|
|
55
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
56
|
+
try {
|
|
57
|
+
return await next();
|
|
58
|
+
} catch (error) {
|
|
59
|
+
lastError = error;
|
|
60
|
+
|
|
61
|
+
if (attempt >= maxRetries) break;
|
|
62
|
+
if (!isRetryable(error)) break;
|
|
63
|
+
|
|
64
|
+
const elapsed = Date.now() - startTime;
|
|
65
|
+
if (elapsed >= giveUpAfterMs) break;
|
|
66
|
+
|
|
67
|
+
let delay = initialDelayMs * Math.pow(backoffFactor, attempt);
|
|
68
|
+
delay = Math.min(delay, maxDelayMs);
|
|
69
|
+
|
|
70
|
+
if (jitter) {
|
|
71
|
+
// Randomize between 50% and 100% of computed delay
|
|
72
|
+
delay = delay * (0.5 + Math.random() * 0.5);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Don't exceed give-up threshold
|
|
76
|
+
const remaining = giveUpAfterMs - elapsed;
|
|
77
|
+
if (delay > remaining) {
|
|
78
|
+
delay = Math.max(0, remaining);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
onRetry?.(attempt + 1, error, delay);
|
|
82
|
+
ctx.annotations["retry:attempt"] = attempt + 1;
|
|
83
|
+
ctx.annotations["retry:lastError"] = String(error);
|
|
84
|
+
|
|
85
|
+
await sleep(delay);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
throw lastError;
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Decider, type DeciderConfig, type Decision } from "./decider";
|
|
2
|
+
import type { AgentConfig } from "./base-agent";
|
|
3
|
+
import type { ContextStack } from "./context-stack";
|
|
4
|
+
import type { ExecuteMeta } from "./executor";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A routing decision — "where should this go?"
|
|
8
|
+
*/
|
|
9
|
+
export interface Route {
|
|
10
|
+
readonly destination: string;
|
|
11
|
+
readonly priority?: number;
|
|
12
|
+
/** Optional context slice hint — what context the destination should receive. */
|
|
13
|
+
readonly contextSlice?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type RouteHandler<TPayload> = (
|
|
17
|
+
context: string,
|
|
18
|
+
payload: TPayload,
|
|
19
|
+
meta?: ExecuteMeta
|
|
20
|
+
) => Promise<Decision<Route>>;
|
|
21
|
+
|
|
22
|
+
export interface RouterConfig<TPayload = unknown> extends AgentConfig {
|
|
23
|
+
handler: RouteHandler<TPayload>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A Router is a Decider that returns a destination.
|
|
28
|
+
*
|
|
29
|
+
* "Given everything you know about the topology, where does this GO?"
|
|
30
|
+
* The Router has the full map loaded in its context layers.
|
|
31
|
+
* The caller sends a payload and gets back a destination — not the map.
|
|
32
|
+
*/
|
|
33
|
+
export class Router<TPayload = unknown> extends Decider<TPayload, Route> {
|
|
34
|
+
constructor(config: RouterConfig<TPayload>) {
|
|
35
|
+
super(config as DeciderConfig<TPayload, Route>);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
override withStack(stack: ContextStack): Router<TPayload> {
|
|
39
|
+
return new Router<TPayload>({ ...this.agentConfig(stack), handler: this._handler });
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership scope — which thread and project an operation belongs to.
|
|
3
|
+
*
|
|
4
|
+
* Threads bind their context sources and tool calls to a scope so that
|
|
5
|
+
* per-thread captures never leak into other threads or projects. A scope
|
|
6
|
+
* with neither field is "unscoped": it sees only knowledge that was
|
|
7
|
+
* explicitly published to everyone.
|
|
8
|
+
*/
|
|
9
|
+
export interface OwnershipScope {
|
|
10
|
+
readonly threadId?: string;
|
|
11
|
+
readonly projectId?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Copy a scope, dropping undefined fields so equality checks are stable. */
|
|
15
|
+
export function normalizeScope(scope: OwnershipScope | undefined): OwnershipScope {
|
|
16
|
+
const out: { threadId?: string; projectId?: string } = {};
|
|
17
|
+
if (scope?.threadId) out.threadId = scope.threadId;
|
|
18
|
+
if (scope?.projectId) out.projectId = scope.projectId;
|
|
19
|
+
return out;
|
|
20
|
+
}
|
package/src/signal.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal kinds — the Librarian's taxonomy.
|
|
3
|
+
* Extensible via string union, not a closed enum.
|
|
4
|
+
*/
|
|
5
|
+
export type SignalKind =
|
|
6
|
+
| "correction"
|
|
7
|
+
| "convention"
|
|
8
|
+
| "taste"
|
|
9
|
+
| "ci_rule"
|
|
10
|
+
| "adr"
|
|
11
|
+
| "security"
|
|
12
|
+
| (string & {});
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A signal — a typed piece of information flowing through the system.
|
|
16
|
+
* Signals are the atoms of the capture → classify → route → verify loop.
|
|
17
|
+
*/
|
|
18
|
+
export interface Signal<T = unknown> {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly kind: SignalKind;
|
|
21
|
+
readonly source: string; // agent id or "user" or system id
|
|
22
|
+
readonly content: T;
|
|
23
|
+
readonly confidence?: number;
|
|
24
|
+
readonly timestamp: number;
|
|
25
|
+
readonly refs?: Array<{ system: string; locator: string }>; // pointers to related knowledge
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type SignalHandler<T = unknown> = (signal: Signal<T>) => void | Promise<void>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A pub/sub bus for signals.
|
|
32
|
+
*
|
|
33
|
+
* Agents emit signals as side-channel information.
|
|
34
|
+
* Observers (Librarian, Herald, writeback rules) subscribe
|
|
35
|
+
* to signal kinds and act on them.
|
|
36
|
+
*
|
|
37
|
+
* Separate from dispatch — signals are observations about what happened,
|
|
38
|
+
* not the primary output.
|
|
39
|
+
*/
|
|
40
|
+
export class SignalBus {
|
|
41
|
+
private _handlers: Map<string, SignalHandler[]> = new Map();
|
|
42
|
+
private _globalHandlers: SignalHandler[] = [];
|
|
43
|
+
private _history: Signal[] = [];
|
|
44
|
+
private _maxHistory: number;
|
|
45
|
+
|
|
46
|
+
constructor(maxHistory: number = 1000) {
|
|
47
|
+
this._maxHistory = maxHistory;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Subscribe to a specific signal kind. */
|
|
51
|
+
on(kind: SignalKind, handler: SignalHandler): () => void {
|
|
52
|
+
if (!this._handlers.has(kind)) {
|
|
53
|
+
this._handlers.set(kind, []);
|
|
54
|
+
}
|
|
55
|
+
this._handlers.get(kind)!.push(handler);
|
|
56
|
+
|
|
57
|
+
return () => {
|
|
58
|
+
const handlers = this._handlers.get(kind);
|
|
59
|
+
if (handlers) {
|
|
60
|
+
const idx = handlers.indexOf(handler);
|
|
61
|
+
if (idx !== -1) handlers.splice(idx, 1);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Subscribe to ALL signals. */
|
|
67
|
+
onAny(handler: SignalHandler): () => void {
|
|
68
|
+
this._globalHandlers.push(handler);
|
|
69
|
+
return () => {
|
|
70
|
+
const idx = this._globalHandlers.indexOf(handler);
|
|
71
|
+
if (idx !== -1) this._globalHandlers.splice(idx, 1);
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Emit a signal. Notifies kind-specific and global handlers. One handler failure doesn't kill the rest. */
|
|
76
|
+
async emit(signal: Signal): Promise<void> {
|
|
77
|
+
// Record history
|
|
78
|
+
this._history.push(signal);
|
|
79
|
+
if (this._history.length > this._maxHistory) {
|
|
80
|
+
this._history.shift();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Snapshot handler arrays to avoid mutation during iteration
|
|
84
|
+
const kindHandlers = [...(this._handlers.get(signal.kind) ?? [])];
|
|
85
|
+
const globalHandlers = [...this._globalHandlers];
|
|
86
|
+
|
|
87
|
+
// Run all handlers — one failure doesn't block the rest
|
|
88
|
+
for (const handler of kindHandlers) {
|
|
89
|
+
try {
|
|
90
|
+
await handler(signal);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.warn(`[SignalBus] handler error for signal "${signal.kind}":`, (err as Error).message ?? err);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const handler of globalHandlers) {
|
|
97
|
+
try {
|
|
98
|
+
await handler(signal);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.warn(`[SignalBus] global handler error for signal "${signal.kind}":`, (err as Error).message ?? err);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Get recent signals, optionally filtered by kind. */
|
|
106
|
+
recent(kind?: SignalKind, limit: number = 50): ReadonlyArray<Signal> {
|
|
107
|
+
const filtered = kind
|
|
108
|
+
? this._history.filter((s) => s.kind === kind)
|
|
109
|
+
: this._history;
|
|
110
|
+
return filtered.slice(-limit);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Clear history. */
|
|
114
|
+
clearHistory(): void {
|
|
115
|
+
this._history = [];
|
|
116
|
+
}
|
|
117
|
+
}
|