@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,31 @@
1
+ /**
2
+ * History-window context layer — caps the number of items projected to the LLM
3
+ * without touching `itemLog` storage. Pure read-side projection via the
4
+ * `projectHistory` hook.
5
+ *
6
+ * Algorithm per turn:
7
+ * 1. Slice the last `maxItems`.
8
+ * 2. If the slice lacks a user `message` or an assistant `message`, expand
9
+ * backward until both are present. The cap may be exceeded, but only up to
10
+ * a hard bound (`maxItems * MAX_EXPANSION_MULTIPLE`) — a tool-only burst
11
+ * cannot grow the window back to the start of history.
12
+ * 3. Re-attach a system/anchor message from the head if it fell outside the
13
+ * window, so core instructions survive windowing.
14
+ * 4. Drop any orphan `function_call` / `function_call_output` left at the
15
+ * slice boundary via `stripUnresolvedToolCalls`.
16
+ */
17
+ import type { ContextLayer } from '@noetic-tools/types';
18
+ /** @public Configuration for the {@link historyWindow} layer. */
19
+ export interface HistoryWindowConfig {
20
+ /** Cap on the number of trailing items projected to the LLM. Defaults to 40. */
21
+ maxItems?: number;
22
+ }
23
+ /**
24
+ * Creates a history-window context layer that caps the trailing items projected
25
+ * to the LLM. Storage stays unbounded; only the wire payload is narrowed.
26
+ *
27
+ * @public
28
+ * @param config - Optional `{ maxItems }`. Defaults to 40.
29
+ * @returns A `ContextLayer` that contributes only a `projectHistory` hook.
30
+ */
31
+ export declare function historyWindow(config?: HistoryWindowConfig): ContextLayer<null>;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * History-window context layer — caps the number of items projected to the LLM
3
+ * without touching `itemLog` storage. Pure read-side projection via the
4
+ * `projectHistory` hook.
5
+ *
6
+ * Algorithm per turn:
7
+ * 1. Slice the last `maxItems`.
8
+ * 2. If the slice lacks a user `message` or an assistant `message`, expand
9
+ * backward until both are present. The cap may be exceeded, but only up to
10
+ * a hard bound (`maxItems * MAX_EXPANSION_MULTIPLE`) — a tool-only burst
11
+ * cannot grow the window back to the start of history.
12
+ * 3. Re-attach a system/anchor message from the head if it fell outside the
13
+ * window, so core instructions survive windowing.
14
+ * 4. Drop any orphan `function_call` / `function_call_output` left at the
15
+ * slice boundary via `stripUnresolvedToolCalls`.
16
+ */
17
+ import { isAssistantMessage, isUserMessage, NoeticConfigError, Slot } from '@noetic-tools/types';
18
+ import { stripUnresolvedToolCalls } from '../strip-unresolved.js';
19
+ //#region Constants
20
+ const DEFAULT_MAX_ITEMS = 40;
21
+ const MIN_MAX_ITEMS = 2;
22
+ const MAX_MAX_ITEMS = 1e4;
23
+ const HISTORY_WINDOW_SLOT = Slot.PROCEDURAL + 25; // 275 — runs after recall-contributing layers
24
+ // Hard upper bound on how far minimum-exchange expansion may grow the window,
25
+ // expressed as a multiple of maxItems. Without this, a tool-only burst lets the
26
+ // window expand back to the first role-pair, defeating the cap entirely.
27
+ const MAX_EXPANSION_MULTIPLE = 4;
28
+ // How many leading items to scan for a system/anchor message to preserve.
29
+ const ANCHOR_SCAN_LIMIT = 4;
30
+ //#endregion
31
+ //#region Helpers
32
+ function validateMaxItems(value) {
33
+ if (!Number.isInteger(value) || value < MIN_MAX_ITEMS || value > MAX_MAX_ITEMS) {
34
+ throw new NoeticConfigError({
35
+ code: 'INVALID_HISTORY_WINDOW_MAX_ITEMS',
36
+ message: `historyWindow: maxItems must be an integer in [${MIN_MAX_ITEMS}, ${MAX_MAX_ITEMS}], got ${value}`,
37
+ hint: 'Pass a sensible cap such as { maxItems: 40 }, or omit the layer to keep history uncapped.',
38
+ });
39
+ }
40
+ }
41
+ /**
42
+ * Walk backwards from the natural slice point until both a user message and
43
+ * an assistant message are present. Returns the index at which the projected
44
+ * window should start in the full `items` array.
45
+ */
46
+ function expandToMinimumExchange(items, sliceStart) {
47
+ let start = sliceStart;
48
+ let needsUser = true;
49
+ let needsAssistant = true;
50
+ // Pass 1: scan the natural slice for the user/assistant pair. Early-return
51
+ // at the original sliceStart when both roles are already inside.
52
+ for (let i = items.length - 1; i >= start; i--) {
53
+ const item = items[i];
54
+ if (needsUser && isUserMessage(item)) {
55
+ needsUser = false;
56
+ }
57
+ if (needsAssistant && isAssistantMessage(item)) {
58
+ needsAssistant = false;
59
+ }
60
+ if (!needsUser && !needsAssistant) {
61
+ return start;
62
+ }
63
+ }
64
+ // Pass 2: walk backward past the slice until the missing role(s) are found.
65
+ for (let i = start - 1; i >= 0 && (needsUser || needsAssistant); i--) {
66
+ const item = items[i];
67
+ if (needsUser && isUserMessage(item)) {
68
+ needsUser = false;
69
+ start = i;
70
+ continue;
71
+ }
72
+ if (needsAssistant && isAssistantMessage(item)) {
73
+ needsAssistant = false;
74
+ start = i;
75
+ }
76
+ }
77
+ return start;
78
+ }
79
+ /**
80
+ * Find a system/anchor message in the leading items that falls outside the
81
+ * projected window (`windowStart`). Returns it so it can be re-attached to the
82
+ * head of the projection; returns null when there is none.
83
+ */
84
+ function findHeadAnchor(items, windowStart) {
85
+ const limit = Math.min(windowStart, ANCHOR_SCAN_LIMIT);
86
+ for (let i = 0; i < limit; i++) {
87
+ const item = items[i];
88
+ if (item.type === 'message' && item.role === 'system') {
89
+ return item;
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+ //#endregion
95
+ //#region Public API
96
+ /**
97
+ * Creates a history-window context layer that caps the trailing items projected
98
+ * to the LLM. Storage stays unbounded; only the wire payload is narrowed.
99
+ *
100
+ * @public
101
+ * @param config - Optional `{ maxItems }`. Defaults to 40.
102
+ * @returns A `ContextLayer` that contributes only a `projectHistory` hook.
103
+ */
104
+ export function historyWindow(config) {
105
+ const maxItems = config?.maxItems ?? DEFAULT_MAX_ITEMS;
106
+ validateMaxItems(maxItems);
107
+ return {
108
+ id: 'history-window',
109
+ name: 'History Window',
110
+ slot: HISTORY_WINDOW_SLOT,
111
+ scope: 'execution',
112
+ hooks: {
113
+ async init() {
114
+ return {
115
+ state: null,
116
+ };
117
+ },
118
+ async projectHistory({ items }) {
119
+ if (items.length <= maxItems) {
120
+ return {
121
+ items,
122
+ };
123
+ }
124
+ const sliceStart = items.length - maxItems;
125
+ let expandedStart = expandToMinimumExchange(items, sliceStart);
126
+ // Hard upper bound: minimum-exchange expansion (e.g. for a tool-only
127
+ // burst) must never grow the window without limit. Drop the oldest
128
+ // excess so the projected count stays a bounded multiple of maxItems.
129
+ const maxWindow = maxItems * MAX_EXPANSION_MULTIPLE;
130
+ if (items.length - expandedStart > maxWindow) {
131
+ expandedStart = items.length - maxWindow;
132
+ }
133
+ const window = items.slice(expandedStart);
134
+ // Preserve a system/anchor message at the head even when it falls
135
+ // outside the trailing window.
136
+ const anchor = findHeadAnchor(items, expandedStart);
137
+ const cleaned = stripUnresolvedToolCalls(anchor
138
+ ? [
139
+ anchor,
140
+ ...window,
141
+ ]
142
+ : window);
143
+ return {
144
+ items: cleaned,
145
+ };
146
+ },
147
+ },
148
+ };
149
+ }
150
+ //#endregion
@@ -0,0 +1,56 @@
1
+ export interface ObservationalState {
2
+ observations: string[];
3
+ buffer: string[];
4
+ bufferTokens: number;
5
+ version: number;
6
+ }
7
+ export type ObserverFn = (buffer: string[]) => Promise<string[]>;
8
+ export interface ObservationalContextConfig {
9
+ bufferThreshold?: number;
10
+ maxObservations?: number;
11
+ scope?: 'thread' | 'resource';
12
+ observer?: ObserverFn;
13
+ }
14
+ /**
15
+ * Creates a context layer that buffers raw items and distills them into observations when a token threshold is reached.
16
+ *
17
+ * @public
18
+ * @param config - Optional configuration for buffer threshold, max observations, scope, and observer function.
19
+ * @returns A `ContextLayer` that accumulates and summarizes observations over time.
20
+ */
21
+ export declare function observationalContext(config?: ObservationalContextConfig): {
22
+ id: 'observational-context';
23
+ name: string;
24
+ slot: 200;
25
+ scope: "resource" | "thread";
26
+ budget: {
27
+ min: number;
28
+ max: number;
29
+ };
30
+ timeouts: {
31
+ store: number;
32
+ onItemAppend: number;
33
+ };
34
+ hooks: {
35
+ init({ storage }: import("@noetic-tools/types").InitParams): Promise<{
36
+ state: ObservationalState;
37
+ }>;
38
+ recall({ state, budget }: import("@noetic-tools/types").RecallParams<ObservationalState>): Promise<{
39
+ items: import("@noetic-tools/types").InputMessageItem[];
40
+ tokenCount: number;
41
+ } | null>;
42
+ store({ newItems, state }: import("@noetic-tools/types").StoreParams<ObservationalState>): Promise<{
43
+ state: ObservationalState;
44
+ }>;
45
+ onItemAppend({ items, state }: import("@noetic-tools/types").OnItemAppendParams<ObservationalState>): Promise<{
46
+ items: import("@noetic-tools/types").Item[];
47
+ state?: undefined;
48
+ } | {
49
+ items: import("@noetic-tools/types").Item[];
50
+ state: ObservationalState;
51
+ }>;
52
+ onSpawn({ parentState }: import("@noetic-tools/types").SpawnParams<ObservationalState>): Promise<{
53
+ childState: ObservationalState;
54
+ }>;
55
+ };
56
+ };
@@ -0,0 +1,152 @@
1
+ import { collectInputText, collectOutputText, createMessage, estimateTokens, Slot, } from '@noetic-tools/types';
2
+ const DEFAULT_BUFFER_THRESHOLD_TOKENS = 2_000;
3
+ const DEFAULT_MAX_OBSERVATIONS = 50;
4
+ function emptyObservationalState() {
5
+ return {
6
+ observations: [],
7
+ buffer: [],
8
+ bufferTokens: 0,
9
+ version: 0,
10
+ };
11
+ }
12
+ /**
13
+ * Appends `texts` into the layer buffer and, once the token threshold is crossed,
14
+ * distills the buffer into observations. Shared by `store` (assistant output) and
15
+ * `onItemAppend` (user/tool input).
16
+ */
17
+ async function accumulate(s, texts, cfg) {
18
+ const newBuffer = [
19
+ ...s.buffer,
20
+ ...texts,
21
+ ];
22
+ const newTokens = texts.reduce((sum, t) => sum + estimateTokens(t), 0);
23
+ const totalBufferTokens = s.bufferTokens + newTokens;
24
+ if (totalBufferTokens >= cfg.threshold) {
25
+ const distilled = cfg.observer
26
+ ? await cfg.observer(newBuffer)
27
+ : [
28
+ `Processed ${newBuffer.length} items`,
29
+ ];
30
+ const newObservations = [
31
+ ...s.observations,
32
+ ...distilled,
33
+ ].slice(-cfg.maxObs);
34
+ return {
35
+ observations: newObservations,
36
+ buffer: [],
37
+ bufferTokens: 0,
38
+ version: s.version + 1,
39
+ };
40
+ }
41
+ return {
42
+ ...s,
43
+ buffer: newBuffer,
44
+ bufferTokens: totalBufferTokens,
45
+ };
46
+ }
47
+ /** Render observations as a tagged bullet list. */
48
+ function renderObservations(observations) {
49
+ const bullets = observations.map((o) => `- ${o}`).join('\n');
50
+ return `<observations>\n${bullets}\n</observations>`;
51
+ }
52
+ /**
53
+ * Render the most recent observations that fit within `budget` tokens (rendered
54
+ * output included). Drops oldest observations first; returns null if not even
55
+ * the single newest observation fits.
56
+ */
57
+ function renderObservationsWithinBudget(observations, budget) {
58
+ for (let start = 0; start < observations.length; start++) {
59
+ const text = renderObservations(observations.slice(start));
60
+ if (estimateTokens(text) <= budget) {
61
+ return text;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+ /**
67
+ * Creates a context layer that buffers raw items and distills them into observations when a token threshold is reached.
68
+ *
69
+ * @public
70
+ * @param config - Optional configuration for buffer threshold, max observations, scope, and observer function.
71
+ * @returns A `ContextLayer` that accumulates and summarizes observations over time.
72
+ */
73
+ export function observationalContext(config) {
74
+ const maxObs = config?.maxObservations ?? DEFAULT_MAX_OBSERVATIONS;
75
+ const threshold = config?.bufferThreshold ?? DEFAULT_BUFFER_THRESHOLD_TOKENS;
76
+ const observer = config?.observer;
77
+ return {
78
+ id: 'observational-context',
79
+ name: 'Observational Context',
80
+ slot: Slot.OBSERVATIONS,
81
+ scope: config?.scope ?? 'resource',
82
+ budget: {
83
+ min: 500,
84
+ max: 2_500,
85
+ },
86
+ timeouts: {
87
+ store: 60_000,
88
+ // onItemAppend runs the same LLM-backed accumulate/distill path as
89
+ // store — it needs the same headroom, not the 5s pipeline default.
90
+ onItemAppend: 60_000,
91
+ },
92
+ hooks: {
93
+ async init({ storage }) {
94
+ const saved = await storage.get('state');
95
+ return {
96
+ state: saved ?? emptyObservationalState(),
97
+ };
98
+ },
99
+ async recall({ state, budget }) {
100
+ if (!state?.observations?.length) {
101
+ return null;
102
+ }
103
+ // Trim to the most recent observations that fit within the token budget.
104
+ const text = renderObservationsWithinBudget(state.observations, budget);
105
+ if (text === null) {
106
+ return null;
107
+ }
108
+ return {
109
+ items: [
110
+ createMessage(text, 'developer'),
111
+ ],
112
+ tokenCount: estimateTokens(text),
113
+ };
114
+ },
115
+ // Captures assistant output text.
116
+ async store({ newItems, state }) {
117
+ const s = state ?? emptyObservationalState();
118
+ const texts = collectOutputText(newItems);
119
+ return {
120
+ state: await accumulate(s, texts, {
121
+ threshold,
122
+ maxObs,
123
+ observer,
124
+ }),
125
+ };
126
+ },
127
+ // Captures user input and tool output text (pass-through; no transform).
128
+ async onItemAppend({ items, state }) {
129
+ const s = state ?? emptyObservationalState();
130
+ const texts = collectInputText(items);
131
+ if (texts.length === 0) {
132
+ return {
133
+ items,
134
+ };
135
+ }
136
+ return {
137
+ items,
138
+ state: await accumulate(s, texts, {
139
+ threshold,
140
+ maxObs,
141
+ observer,
142
+ }),
143
+ };
144
+ },
145
+ async onSpawn({ parentState }) {
146
+ return {
147
+ childState: structuredClone(parentState),
148
+ };
149
+ },
150
+ },
151
+ };
152
+ }
@@ -0,0 +1,73 @@
1
+ import type { ContextLayer, ContextScope, WorkflowDocument, WorkflowNode } from '@noetic-tools/types';
2
+ export declare const PlanPhase: {
3
+ readonly Idle: 'idle';
4
+ readonly Planning: 'planning';
5
+ readonly Executing: 'executing';
6
+ readonly Completed: 'completed';
7
+ readonly Failed: 'failed';
8
+ };
9
+ export type PlanPhase = (typeof PlanPhase)[keyof typeof PlanPhase];
10
+ export interface PlanExecutionEntry {
11
+ timestamp: number;
12
+ version: number;
13
+ outcome: 'success' | 'failure' | 'aborted';
14
+ }
15
+ export interface PlanState {
16
+ phase: PlanPhase;
17
+ prd: string | null;
18
+ /** The reviewed plan: a complete JSON workflow document. */
19
+ planTree: WorkflowDocument | null;
20
+ /** Named workflows referenced from the tree via `{ kind: 'subflow', ref }` nodes. */
21
+ workflows: Record<string, WorkflowDocument>;
22
+ executionLog: PlanExecutionEntry[];
23
+ version: number;
24
+ /** Identifier of the on-disk plan session (set by `onEnterSession` host callback). */
25
+ planSlug?: string | null;
26
+ }
27
+ /** Host-supplied callback invoked when entering plan mode. Returns a session identifier the host owns (e.g. on-disk dir slug). */
28
+ export type PlanEnterSessionCallback = () => Promise<{
29
+ slug: string;
30
+ }>;
31
+ /**
32
+ * Host-supplied callback invoked when the model requests `exitPlanMode` with `action: 'execute'`.
33
+ * Return `{ approved: false }` to keep the layer in `Planning` (e.g. user rejected the plan in the UI).
34
+ */
35
+ export type PlanExitCallback = (state: PlanState) => Promise<{
36
+ approved: boolean;
37
+ }>;
38
+ export interface PlanContextConfig {
39
+ scope?: ContextScope;
40
+ additionalAllowedTools?: string[];
41
+ maxPrdLength?: number;
42
+ /** Max structural depth (`workflowDepth`) of the plan tree and each named workflow. Default 5. */
43
+ maxDepth?: number;
44
+ /** Max number of named workflows a plan may store. Default 20. */
45
+ maxWorkflows?: number;
46
+ /** Max serialized size (`JSON.stringify` length) of each workflow document. Default 20000. */
47
+ maxWorkflowChars?: number;
48
+ /**
49
+ * Optional profile restricting which `WorkflowNode` kinds the plan tree and
50
+ * named workflows may use. Undefined allows all kinds. Hosts that set this
51
+ * must include `'subflow'` for named workflows to be referenceable.
52
+ */
53
+ allowedNodeKinds?: WorkflowNode['kind'][];
54
+ /** Extra free-form instructions appended to the planning-phase recall payload. */
55
+ additionalPlanInstructions?: string;
56
+ /** Called once when the layer transitions Idle → Planning. */
57
+ onEnterSession?: PlanEnterSessionCallback;
58
+ /** Called when `exitPlanMode` is requested with `action: 'execute'`. */
59
+ onExit?: PlanExitCallback;
60
+ }
61
+ /**
62
+ * Creates a plan context layer that manages the PRD authoring and plan execution lifecycle.
63
+ *
64
+ * The plan tree is a JSON `WorkflowDocument`; plans additionally store named
65
+ * workflows referenced from the tree via `subflow` nodes, keeping the reviewed
66
+ * tree small. On approval, hosts execute the plan by feeding `planTree` and
67
+ * `workflows` to the JSON workflow runtime (`parseAndRunWorkflow`).
68
+ *
69
+ * @public
70
+ * @param config - Optional configuration for scope, allowed tools, and limits.
71
+ * @returns A `ContextLayer` providing plan mode, PRD storage, and execution tracking.
72
+ */
73
+ export declare function planContext(config?: PlanContextConfig): ContextLayer<PlanState>;