@noetic-tools/context 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +28 -0
  3. package/dist/context/budget.d.ts +39 -0
  4. package/dist/context/budget.js +183 -0
  5. package/dist/context/exec-context-factory.d.ts +24 -0
  6. package/dist/context/exec-context-factory.js +30 -0
  7. package/dist/context/function-call-utils.d.ts +6 -0
  8. package/dist/context/function-call-utils.js +27 -0
  9. package/dist/context/layer-api.d.ts +11 -0
  10. package/dist/context/layer-api.js +91 -0
  11. package/dist/context/layer-lifecycle.d.ts +239 -0
  12. package/dist/context/layer-lifecycle.js +800 -0
  13. package/dist/context/layer-provides.d.ts +32 -0
  14. package/dist/context/layer-provides.js +33 -0
  15. package/dist/context/layer-usage.d.ts +13 -0
  16. package/dist/context/layer-usage.js +54 -0
  17. package/dist/context/layers/durable-task-state.d.ts +107 -0
  18. package/dist/context/layers/durable-task-state.js +250 -0
  19. package/dist/context/layers/file-reference.d.ts +55 -0
  20. package/dist/context/layers/file-reference.js +729 -0
  21. package/dist/context/layers/history-window.d.ts +31 -0
  22. package/dist/context/layers/history-window.js +150 -0
  23. package/dist/context/layers/observational-context.d.ts +56 -0
  24. package/dist/context/layers/observational-context.js +152 -0
  25. package/dist/context/layers/plan-context.d.ts +73 -0
  26. package/dist/context/layers/plan-context.js +749 -0
  27. package/dist/context/layers/static-content.d.ts +27 -0
  28. package/dist/context/layers/static-content.js +46 -0
  29. package/dist/context/layers/steering.d.ts +50 -0
  30. package/dist/context/layers/steering.js +291 -0
  31. package/dist/context/layers/temporal-context.d.ts +91 -0
  32. package/dist/context/layers/temporal-context.js +327 -0
  33. package/dist/context/layers/tool-context-layer.d.ts +15 -0
  34. package/dist/context/layers/tool-context-layer.js +43 -0
  35. package/dist/context/layers/working-memory-context.d.ts +46 -0
  36. package/dist/context/layers/working-memory-context.js +155 -0
  37. package/dist/context/projector.d.ts +19 -0
  38. package/dist/context/projector.js +89 -0
  39. package/dist/context/scope.d.ts +3 -0
  40. package/dist/context/scope.js +42 -0
  41. package/dist/context/storage-batch.d.ts +18 -0
  42. package/dist/context/storage-batch.js +35 -0
  43. package/dist/context/strip-unresolved.d.ts +13 -0
  44. package/dist/context/strip-unresolved.js +35 -0
  45. package/dist/deprecated.d.ts +43 -0
  46. package/dist/deprecated.js +28 -0
  47. package/dist/index.d.ts +29 -0
  48. package/dist/index.js +31 -0
  49. package/package.json +63 -0
@@ -0,0 +1,27 @@
1
+ import type { ContextScope } from '@noetic-tools/types';
2
+ interface StaticContentOpts {
3
+ id?: string;
4
+ slot?: number;
5
+ scope?: ContextScope;
6
+ load: () => Promise<string>;
7
+ tag?: string;
8
+ }
9
+ /**
10
+ * Creates a read-only context layer that loads static content once at init and injects it into recall.
11
+ *
12
+ * @public
13
+ * @param opts - Configuration with a `load` function and optional id, slot, scope, and XML tag name.
14
+ * @returns A `ContextLayer` that provides static instructional content to the model.
15
+ */
16
+ export declare function staticContent(opts: StaticContentOpts): {
17
+ id: string;
18
+ slot: number;
19
+ scope: ContextScope;
20
+ hooks: {
21
+ init(): Promise<{
22
+ state: string;
23
+ }>;
24
+ recall({ state, budget }: import("@noetic-tools/types").RecallParams<string>): Promise<string | null>;
25
+ };
26
+ };
27
+ export {};
@@ -0,0 +1,46 @@
1
+ import { estimateTokens, Slot } from '@noetic-tools/types';
2
+ /**
3
+ * Creates a read-only context layer that loads static content once at init and injects it into recall.
4
+ *
5
+ * @public
6
+ * @param opts - Configuration with a `load` function and optional id, slot, scope, and XML tag name.
7
+ * @returns A `ContextLayer` that provides static instructional content to the model.
8
+ */
9
+ export function staticContent(opts) {
10
+ const tag = opts.tag ?? 'instructions';
11
+ return {
12
+ id: opts.id ?? 'static-content',
13
+ slot: opts.slot ?? Slot.WORKING_MEMORY + 5,
14
+ scope: opts.scope ?? 'resource',
15
+ hooks: {
16
+ async init() {
17
+ const raw = await opts.load();
18
+ if (!raw) {
19
+ return {
20
+ state: '',
21
+ };
22
+ }
23
+ return {
24
+ state: `<${tag}>\n${raw}\n</${tag}>`,
25
+ };
26
+ },
27
+ async recall({ state, budget }) {
28
+ if (!state) {
29
+ return null;
30
+ }
31
+ // `budget > 0` is a deliberate fail-open guard: a zero allocation must
32
+ // not delete the instructions (slice(0, 0) would silently drop the
33
+ // whole layer from the view).
34
+ if (budget > 0 && estimateTokens(state) > budget) {
35
+ // Trim so the recalled text fits the token budget (~4 chars/token),
36
+ // reserving room for the closing tag so the block stays well-formed
37
+ // XML instead of being cut mid-tag/mid-sentence.
38
+ const closing = `\n</${tag}>`;
39
+ const maxChars = Math.max(0, budget * 4 - closing.length);
40
+ return `${state.slice(0, maxChars)}${closing}`;
41
+ }
42
+ return state;
43
+ },
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,50 @@
1
+ import type { AfterModelCallParams, BeforeToolCallParams, ContextScope, LedgerEntry, SteeringConfig, SteeringDecision, SteeringState } from '@noetic-tools/types';
2
+ /**
3
+ * Creates a steering context layer that evaluates rules before tool calls and after model calls.
4
+ *
5
+ * @public
6
+ * @param config - Steering configuration including rules, scope, and max ledger size.
7
+ * @returns A `ContextLayer` that enforces steering rules via allow/deny/guide decisions.
8
+ */
9
+ export declare function steering(config: SteeringConfig): {
10
+ id: 'steering';
11
+ name: string;
12
+ slot: 90;
13
+ scope: ContextScope;
14
+ budget: {
15
+ min: number;
16
+ max: number;
17
+ };
18
+ timeouts: {
19
+ beforeToolCall: number;
20
+ afterModelCall: number;
21
+ };
22
+ hooks: {
23
+ init({ storage }: import("@noetic-tools/types").InitParams): Promise<{
24
+ state: SteeringState;
25
+ }>;
26
+ recall({ state }: import("@noetic-tools/types").RecallParams<SteeringState>): Promise<{
27
+ items: import("@noetic-tools/types").InputMessageItem[];
28
+ tokenCount: number;
29
+ state: SteeringState;
30
+ } | null>;
31
+ store(): Promise<undefined>;
32
+ beforeToolCall({ toolName, toolArgs, ctx, state }: BeforeToolCallParams<SteeringState>): Promise<{
33
+ decision: SteeringDecision;
34
+ state: SteeringState;
35
+ }>;
36
+ afterModelCall({ response, ctx, state }: AfterModelCallParams<SteeringState>): Promise<{
37
+ decision: SteeringDecision;
38
+ state: SteeringState;
39
+ }>;
40
+ onSpawn({ parentState }: import("@noetic-tools/types").SpawnParams<SteeringState>): Promise<{
41
+ childState: {
42
+ ledger: LedgerEntry[];
43
+ pendingAsync: never[];
44
+ };
45
+ }>;
46
+ onComplete({ state, outcome }: import("@noetic-tools/types").CompleteParams<SteeringState>): Promise<{
47
+ state: SteeringState;
48
+ }>;
49
+ };
50
+ };
@@ -0,0 +1,291 @@
1
+ import { createMessage, estimateTokens, extractAssistantText, LedgerEntryKind, NoeticConfigError, Slot, SteeringAction, } from '@noetic-tools/types';
2
+ import { mostRestrictive } from '../layer-lifecycle.js';
3
+ //#region Constants
4
+ const DEFAULT_MAX_LEDGER_ENTRIES = 100;
5
+ //#endregion
6
+ //#region Rule Evaluation Helpers
7
+ function requireCallModel(params, ruleId) {
8
+ if (!params.ctx.callModel) {
9
+ throw new NoeticConfigError({
10
+ code: 'MISSING_CALL_MODEL',
11
+ message: `LLM-evaluated steering rule "${ruleId}" requires a callModel but none is available.`,
12
+ hint: 'Pass `llm: { provider: "openrouter", apiKey: "..." }` to AgentHarness or set OPENROUTER_API_KEY.',
13
+ });
14
+ }
15
+ }
16
+ function evaluateProgrammaticRule(rule, params) {
17
+ if (!rule.predicate) {
18
+ return null;
19
+ }
20
+ return rule.predicate(params);
21
+ }
22
+ /**
23
+ * Parse an LLM steering verdict. Returns `null` when the output is unparseable
24
+ * (neither ALLOW/DENY/GUIDE) so the caller can retry. The verdict keyword is
25
+ * matched case-insensitively, but the guidance text is preserved verbatim.
26
+ */
27
+ function parseLlmVerdict(raw) {
28
+ const text = raw.trim();
29
+ // Match the verdict keyword on a word boundary (so "DENYALL" is NOT a DENY),
30
+ // case-insensitively, but keep the guidance text's original casing.
31
+ if (/^ALLOW\b/i.test(text)) {
32
+ return {
33
+ action: SteeringAction.Allow,
34
+ };
35
+ }
36
+ if (/^DENY\b/i.test(text)) {
37
+ return {
38
+ action: SteeringAction.Deny,
39
+ guidance: text
40
+ .slice(4)
41
+ .replace(/^[:\s]+/, '')
42
+ .trim() || undefined,
43
+ };
44
+ }
45
+ if (/^GUIDE\b/i.test(text)) {
46
+ return {
47
+ action: SteeringAction.Guide,
48
+ guidance: text
49
+ .slice(5)
50
+ .replace(/^[:\s]+/, '')
51
+ .trim() || undefined,
52
+ };
53
+ }
54
+ return null;
55
+ }
56
+ async function evaluateLlmRuleSync(rule, params, maxRetries) {
57
+ if (!rule.llmEval) {
58
+ return {
59
+ action: SteeringAction.Allow,
60
+ };
61
+ }
62
+ const { callModel } = params.ctx;
63
+ if (!callModel) {
64
+ return {
65
+ action: SteeringAction.Allow,
66
+ };
67
+ }
68
+ const contextSummary = 'toolName' in params
69
+ ? `Tool: ${params.toolName}, Args: ${JSON.stringify(params.toolArgs)}`
70
+ : `Response items: ${params.response.items.length}`;
71
+ const prompt = `${rule.llmEval.prompt}\n\nContext: ${contextSummary}\n\nRespond with exactly "ALLOW", "DENY", or "GUIDE: <guidance text>".`;
72
+ const userMessage = createMessage(prompt, 'user');
73
+ const model = rule.llmEval.model ?? 'openai/gpt-4o-mini';
74
+ // Retry on unparseable output up to maxRetries; treat as pass on exhaustion.
75
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
76
+ const response = await callModel({
77
+ model,
78
+ items: [
79
+ userMessage,
80
+ ],
81
+ });
82
+ const verdict = parseLlmVerdict(extractAssistantText(response.items));
83
+ if (verdict) {
84
+ return verdict;
85
+ }
86
+ }
87
+ return {
88
+ action: SteeringAction.Allow,
89
+ };
90
+ }
91
+ function fireLlmRuleAsync({ rule, params, pendingAsync, maxRetries, }) {
92
+ evaluateLlmRuleSync(rule, params, maxRetries)
93
+ .then((decision) => {
94
+ if (decision.action !== SteeringAction.Allow) {
95
+ pendingAsync.push({
96
+ ruleId: rule.id,
97
+ guidance: decision.guidance ?? `Rule ${rule.id}: ${decision.action}`,
98
+ });
99
+ }
100
+ })
101
+ .catch(() => {
102
+ // Async LLM eval failures are silently swallowed — they must not disrupt the agent
103
+ });
104
+ }
105
+ function trimLedger(ledger, max) {
106
+ if (ledger.length <= max) {
107
+ return ledger;
108
+ }
109
+ return ledger.slice(ledger.length - max);
110
+ }
111
+ async function evaluateRules({ rules, hookName, params, state, maxRetries, }) {
112
+ const applicable = rules.filter((r) => r.appliesTo.includes(hookName));
113
+ const decisions = [];
114
+ for (const rule of applicable) {
115
+ // Programmatic predicate (fast)
116
+ const programmatic = evaluateProgrammaticRule(rule, params);
117
+ if (programmatic) {
118
+ decisions.push(programmatic);
119
+ if (programmatic.action === SteeringAction.Deny) {
120
+ return mostRestrictive(decisions);
121
+ }
122
+ continue;
123
+ }
124
+ // LLM eval — throws NoeticConfigError if callModel is missing
125
+ if (!rule.llmEval) {
126
+ continue;
127
+ }
128
+ requireCallModel(params, rule.id);
129
+ if (rule.llmEval.mode === 'sync') {
130
+ const decision = await evaluateLlmRuleSync(rule, params, maxRetries);
131
+ decisions.push(decision);
132
+ if (decision.action === SteeringAction.Deny) {
133
+ return mostRestrictive(decisions);
134
+ }
135
+ continue;
136
+ }
137
+ // Async — fire and forget
138
+ fireLlmRuleAsync({
139
+ rule,
140
+ params,
141
+ pendingAsync: state.pendingAsync,
142
+ maxRetries,
143
+ });
144
+ }
145
+ return mostRestrictive(decisions);
146
+ }
147
+ //#endregion
148
+ //#region Public API
149
+ /**
150
+ * Creates a steering context layer that evaluates rules before tool calls and after model calls.
151
+ *
152
+ * @public
153
+ * @param config - Steering configuration including rules, scope, and max ledger size.
154
+ * @returns A `ContextLayer` that enforces steering rules via allow/deny/guide decisions.
155
+ */
156
+ export function steering(config) {
157
+ const maxLedger = config.maxLedgerEntries ?? DEFAULT_MAX_LEDGER_ENTRIES;
158
+ const maxRetries = config.maxRetries ?? 1;
159
+ const scope = config.scope ?? 'execution';
160
+ return {
161
+ id: 'steering',
162
+ name: 'Steering',
163
+ slot: Slot.STEERING,
164
+ scope,
165
+ budget: {
166
+ min: 0,
167
+ max: 500,
168
+ },
169
+ timeouts: {
170
+ beforeToolCall: 5e3,
171
+ afterModelCall: 1e4,
172
+ },
173
+ hooks: {
174
+ async init({ storage }) {
175
+ const saved = await storage.get('state');
176
+ const state = saved ?? {
177
+ ledger: [],
178
+ pendingAsync: [],
179
+ };
180
+ return {
181
+ state,
182
+ };
183
+ },
184
+ async recall({ state }) {
185
+ if (state.pendingAsync.length === 0) {
186
+ return null;
187
+ }
188
+ // Drain IN PLACE (splice, not reassignment): in-flight async LLM rules
189
+ // hold this array by reference (fireLlmRuleAsync) — replacing it would
190
+ // orphan their late verdicts. Late pushes land in the live array and
191
+ // surface on the next recall.
192
+ const drained = state.pendingAsync.splice(0, state.pendingAsync.length);
193
+ const feedback = drained.map((p) => `[${p.ruleId}] ${p.guidance}`).join('\n');
194
+ const content = `<steering_feedback>\n${feedback}\n</steering_feedback>`;
195
+ return {
196
+ items: [
197
+ createMessage(content, 'developer'),
198
+ ],
199
+ tokenCount: estimateTokens(content),
200
+ state,
201
+ };
202
+ },
203
+ async store() {
204
+ // Model turn ledger entries are recorded in afterModelCall to avoid duplicates
205
+ return undefined;
206
+ },
207
+ async beforeToolCall({ toolName, toolArgs, ctx, state }) {
208
+ const decision = await evaluateRules({
209
+ rules: config.rules,
210
+ hookName: 'beforeToolCall',
211
+ params: {
212
+ toolName,
213
+ toolArgs,
214
+ ctx,
215
+ state,
216
+ },
217
+ state,
218
+ maxRetries,
219
+ });
220
+ const entry = {
221
+ kind: LedgerEntryKind.ToolCall,
222
+ timestamp: Date.now(),
223
+ toolName,
224
+ toolArgs,
225
+ ruleId: decision.action !== SteeringAction.Allow ? 'steering' : undefined,
226
+ action: decision.action,
227
+ guidance: decision.guidance,
228
+ };
229
+ state.ledger.push(entry);
230
+ state.ledger = trimLedger(state.ledger, maxLedger);
231
+ return {
232
+ decision,
233
+ state,
234
+ };
235
+ },
236
+ async afterModelCall({ response, ctx, state }) {
237
+ const decision = await evaluateRules({
238
+ rules: config.rules,
239
+ hookName: 'afterModelCall',
240
+ params: {
241
+ response,
242
+ ctx,
243
+ state,
244
+ },
245
+ state,
246
+ maxRetries,
247
+ });
248
+ const entry = {
249
+ kind: LedgerEntryKind.ModelTurn,
250
+ timestamp: Date.now(),
251
+ tokenUsage: {
252
+ input: response.usage.inputTokens,
253
+ output: response.usage.outputTokens,
254
+ },
255
+ ruleId: decision.action !== SteeringAction.Allow ? 'steering' : undefined,
256
+ action: decision.action,
257
+ guidance: decision.guidance,
258
+ };
259
+ state.ledger.push(entry);
260
+ state.ledger = trimLedger(state.ledger, maxLedger);
261
+ return {
262
+ decision,
263
+ state,
264
+ };
265
+ },
266
+ async onSpawn({ parentState }) {
267
+ return {
268
+ childState: {
269
+ ledger: structuredClone(parentState.ledger),
270
+ pendingAsync: [],
271
+ },
272
+ };
273
+ },
274
+ async onComplete({ state, outcome }) {
275
+ const entry = {
276
+ kind: LedgerEntryKind.Custom,
277
+ timestamp: Date.now(),
278
+ custom: {
279
+ outcome,
280
+ },
281
+ };
282
+ state.ledger.push(entry);
283
+ state.ledger = trimLedger(state.ledger, maxLedger);
284
+ return {
285
+ state,
286
+ };
287
+ },
288
+ },
289
+ };
290
+ }
291
+ //#endregion
@@ -0,0 +1,91 @@
1
+ import type { ContextLayer, ContextScope } from '@noetic-tools/types';
2
+ /**
3
+ * A single remembered fact tied to an instant.
4
+ * @public
5
+ */
6
+ export interface TemporalFact {
7
+ /** ISO-8601 timestamp the fact is associated with (when it happened / was stated). */
8
+ ts: string;
9
+ /** The fact, stated tersely. */
10
+ fact: string;
11
+ }
12
+ /**
13
+ * Result of a temporal fact search.
14
+ * @public
15
+ */
16
+ export interface TemporalSearchResult {
17
+ /** Matching facts, most relevant first. */
18
+ facts: string[];
19
+ /** A resolved date for the query, when one applies (ISO-8601 or a natural phrase). */
20
+ date?: string;
21
+ /** True when `date` is approximate ("around March", "a few weeks ago"). */
22
+ fuzzy?: boolean;
23
+ }
24
+ /**
25
+ * Extracts timestamped facts from a batch of conversation text. Host-injected so
26
+ * the layer stays LLM-agnostic and tree-shakable (mirrors `observationalContext`'s
27
+ * `observer`). When omitted, the layer only buffers — it never fabricates facts.
28
+ * @public
29
+ */
30
+ export type FactExtractor = (input: {
31
+ transcript: string;
32
+ now: string;
33
+ }) => Promise<TemporalFact[]>;
34
+ /**
35
+ * Searches the stored fact ledger for a query, optionally resolving a date.
36
+ * Host-injected. When omitted, search falls back to returning the raw ledger.
37
+ * @public
38
+ */
39
+ export type FactSearcher = (input: {
40
+ query: string;
41
+ facts: ReadonlyArray<TemporalFact>;
42
+ now: string;
43
+ }) => Promise<TemporalSearchResult>;
44
+ /**
45
+ * Configuration for {@link temporalContext}.
46
+ * @public
47
+ */
48
+ export interface TemporalContextConfig {
49
+ /** Returns "now" for date grounding/extraction. Defaults to the system clock. */
50
+ now?: () => Date;
51
+ /** Context scope. Defaults to `'resource'` (long-term, cross-session). */
52
+ scope?: ContextScope;
53
+ /** LLM-backed fact extractor. Without it, `store` only buffers (no extraction). */
54
+ extract?: FactExtractor;
55
+ /** LLM-backed fact searcher. Without it, `searchMemory` returns the raw ledger. */
56
+ search?: FactSearcher;
57
+ /** Buffered output tokens that trigger an extraction pass. Default 2000. */
58
+ bufferThreshold?: number;
59
+ /** Maximum facts retained (oldest dropped beyond this). Default 200. */
60
+ maxFacts?: number;
61
+ /** Inject a `<current_datetime>` grounding block on recall. Default true. */
62
+ groundDateTime?: boolean;
63
+ /** Inject the fact ledger on recall (vs. on-demand via the search tool). Default false. */
64
+ injectLedger?: boolean;
65
+ }
66
+ interface TemporalState {
67
+ /** ISO timestamp → facts recorded at that instant. */
68
+ facts: Record<string, string[]>;
69
+ /** Accumulated output text awaiting extraction. */
70
+ buffer: string[];
71
+ bufferTokens: number;
72
+ version: number;
73
+ }
74
+ /**
75
+ * A non-atomic, LLM-backed context layer that extracts timestamped facts from the
76
+ * conversation into a key-value ledger (key = ISO timestamp) and exposes a
77
+ * `temporal/searchMemory` tool for relative-time recall — given a fact/query it
78
+ * returns matching facts and, when applicable, a resolved (possibly fuzzy) date.
79
+ *
80
+ * The layer is LLM-agnostic: the host injects `extract` and `search` callbacks
81
+ * (mirrors `observationalContext`'s `observer`), keeping `context/` tree-shakable.
82
+ * Without them, the layer still buffers text and the search tool returns the raw
83
+ * ledger — it never fabricates facts. A `<current_datetime>` grounding block is
84
+ * injected on recall by default to anchor relative-time reasoning.
85
+ *
86
+ * @public
87
+ * @param config - Clock, scope, injected LLM callbacks, and tuning knobs.
88
+ * @returns A `ContextLayer` with a fact-ledger store + `searchMemory` tool.
89
+ */
90
+ export declare function temporalContext(config?: TemporalContextConfig): ContextLayer<TemporalState>;
91
+ export {};