@intent-framework/core 0.1.0-alpha.1 → 0.1.0-alpha.10

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/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # @intent-framework/core
2
+
3
+ Platformless semantic graph and runtime for Intent applications.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pnpm add @intent-framework/core
9
+ ```
10
+
11
+ ```sh
12
+ npm install @intent-framework/core
13
+ ```
14
+
15
+ ## What it provides
16
+
17
+ - `screen()` — define a semantic interaction space
18
+ - `$.state.text()` / `$.state.boolean()` / `$.state.choice()` — reactive state
19
+ - `$.ask()` — user-facing question with validation
20
+ - `$.act()` — executable action with conditions, lifecycle, and feedback
21
+ - `$.resource()` — async state with load/reload lifecycle
22
+ - `$.surface()` — named containment surface
23
+ - `createScreenRuntime()` — runtime that owns screen state and resources
24
+ - `inspectScreen()` — semantic graph snapshot with diagnostics
25
+ - Condition and signal primitives
26
+
27
+ ## Minimal example
28
+
29
+ ```ts
30
+ import { screen, inspectScreen } from "@intent-framework/core"
31
+
32
+ const InviteMember = screen("InviteMember", $ => {
33
+ const email = $.state.text("email")
34
+
35
+ const emailAsk = $.ask("Email", email)
36
+ .required("Email is required")
37
+ .validate(value => value.includes("@") ? true : "Enter a valid email")
38
+
39
+ const invite = $.act("Invite member")
40
+ .primary()
41
+ .when(emailAsk.valid, "Enter a valid email first")
42
+ .does(() => {
43
+ console.log("invite", email.value)
44
+ })
45
+
46
+ $.surface("main").contains(emailAsk, invite)
47
+ })
48
+
49
+ const graph = inspectScreen(InviteMember)
50
+ console.log(graph.diagnostics)
51
+ ```
52
+
53
+ ## Where this fits
54
+
55
+ Core defines the product graph. It has no DOM, React, Node, or framework dependencies. Renderers (`@intent-framework/dom`), the router (`@intent-framework/router`), and testing (`@intent-framework/testing`) all build on core.
56
+
57
+ ## Learn more
58
+
59
+ - [Root README](../../README.md) — project overview and philosophy
60
+ - [Quickstart](../../docs/Quickstart.md) — step-by-step guide
61
+ - [Inspect Screen and Diagnostics Guide](../../docs/Inspect-Screen.md) — graph inspection and diagnostics
62
+ - [Resources Guide](../../docs/Resources.md) — resource lifecycle and runtime scoping
63
+ - [MVP Checkpoint](../../docs/MVP-Checkpoint.md) — current implementation boundaries
64
+
65
+ ## Status
66
+
67
+ Experimental alpha. APIs may change. Not recommended for production use.
package/dist/graph.d.ts CHANGED
@@ -2,12 +2,17 @@ import type { ScreenDefinition } from "./screen.js";
2
2
  import type { DefaultScreenServices } from "./act.js";
3
3
  import type { AnyResourceNode } from "./resource.js";
4
4
  export type DiagnosticSeverity = "info" | "warning" | "error";
5
+ export type FlowDiagnosticMeta = {
6
+ flowNodeId: string;
7
+ flowSemanticNodeId?: string;
8
+ };
5
9
  export type GraphDiagnostic = {
6
10
  severity: DiagnosticSeverity;
7
11
  code: string;
8
12
  message: string;
9
13
  nodeId?: string;
10
14
  semanticNodeId?: string;
15
+ flow?: FlowDiagnosticMeta;
11
16
  };
12
17
  export type InspectedScreen = {
13
18
  name: string;
package/dist/index.d.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  export { screen } from "./screen.js";
2
2
  export type { ScreenDefinition, ScreenBuilder } from "./screen.js";
3
3
  export { inspectScreen } from "./graph.js";
4
- export type { InspectedScreen, GraphDiagnostic, DiagnosticSeverity } from "./graph.js";
4
+ export type { InspectedScreen, GraphDiagnostic, DiagnosticSeverity, FlowDiagnosticMeta } from "./graph.js";
5
5
  export type { TextState, BooleanState, ChoiceState } from "./state.js";
6
6
  export type { AskNode, AnyAskNode, AskKind, AskBuilder } from "./ask.js";
7
7
  export type { ActNode, ActCondition, ActStatus, FeedbackConfig, ActBuilder, NavigationService, ActionExecutionContext, DefaultScreenServices } from "./act.js";
8
8
  export type { FlowNode, FlowStep, FlowBuilder } from "./flow.js";
9
9
  export type { SurfaceNode, SurfaceBuilder } from "./surface.js";
10
- export type { ResourceNode, ResourceConfig, ResourceLoadContext, ResourceStatus, AnyResourceNode } from "./resource.js";
10
+ export type { ResourceNode, ResourceConfig, ResourceCacheOptions, ResourceLoadContext, ResourceStatus, ResourceKey, AnyResourceNode } from "./resource.js";
11
11
  export { ResourceRef, createResourceNode } from "./resource.js";
12
12
  export { createScreenRuntime } from "./runtime.js";
13
13
  export type { ScreenRuntime } from "./runtime.js";
package/dist/index.js CHANGED
@@ -37,9 +37,25 @@ function signal(initial) {
37
37
 
38
38
  //#endregion
39
39
  //#region src/resource.ts
40
- function createResourceNode(id, name, loader, autoLoad = true) {
40
+ function createResourceNode(id, name, loader, autoLoad = true, cache) {
41
41
  const statusSignal = signal(0);
42
42
  const staleSignal = signal(0);
43
+ const hasKey = typeof cache?.key === "function";
44
+ const DEFAULT_KEY = "";
45
+ function createEntry() {
46
+ return {
47
+ value: void 0,
48
+ status: "idle",
49
+ error: void 0,
50
+ stale: false,
51
+ staleTimer: null,
52
+ cacheTimer: null,
53
+ inFlightPromise: null
54
+ };
55
+ }
56
+ const entries = /* @__PURE__ */ new Map();
57
+ let _activeKey = DEFAULT_KEY;
58
+ if (!hasKey) entries.set(DEFAULT_KEY, createEntry());
43
59
  let currentStatus = "idle";
44
60
  let currentValue = void 0;
45
61
  let currentError = void 0;
@@ -47,10 +63,94 @@ function createResourceNode(id, name, loader, autoLoad = true) {
47
63
  let lastContext = void 0;
48
64
  const notify = () => statusSignal.set(statusSignal.get() + 1);
49
65
  const staleNotify = () => staleSignal.set(staleSignal.get() + 1);
66
+ const shouldDeduplicate = cache ? cache.deduplicate !== false : false;
50
67
  let _ready;
51
68
  let _pending;
52
69
  let _failed;
53
70
  let _stale;
71
+ function getActiveEntry() {
72
+ let entry = entries.get(_activeKey);
73
+ if (!entry) {
74
+ entry = createEntry();
75
+ entries.set(_activeKey, entry);
76
+ }
77
+ return entry;
78
+ }
79
+ function syncFromEntry(entry) {
80
+ currentStatus = entry.status;
81
+ currentValue = entry.value;
82
+ currentError = entry.error;
83
+ if (currentStale !== entry.stale) {
84
+ currentStale = entry.stale;
85
+ staleNotify();
86
+ }
87
+ notify();
88
+ }
89
+ function syncFromActiveEntry() {
90
+ syncFromEntry(getActiveEntry());
91
+ }
92
+ function encodeResourceKey(key) {
93
+ if (Array.isArray(key)) return ["array", key.map(encodeResourceKey)];
94
+ if (key === null) return ["null"];
95
+ if (key === void 0) return ["undefined"];
96
+ if (typeof key === "string") return ["string", key];
97
+ if (typeof key === "boolean") return ["boolean", key];
98
+ if (typeof key === "number") {
99
+ if (Number.isNaN(key)) return ["number", "NaN"];
100
+ if (key === Infinity) return ["number", "Infinity"];
101
+ if (key === -Infinity) return ["number", "-Infinity"];
102
+ if (Object.is(key, -0)) return ["number", "-0"];
103
+ return ["number", key];
104
+ }
105
+ const exhaustive = key;
106
+ return exhaustive;
107
+ }
108
+ function normalizeKey(key) {
109
+ return JSON.stringify(encodeResourceKey(key));
110
+ }
111
+ function resolveKey(ctx) {
112
+ if (!hasKey) return DEFAULT_KEY;
113
+ const context = ctx ?? lastContext ?? {};
114
+ return normalizeKey(cache.key(context));
115
+ }
116
+ function _clearEntryStaleTimer(entry) {
117
+ if (entry.staleTimer != null) {
118
+ clearTimeout(entry.staleTimer);
119
+ entry.staleTimer = null;
120
+ }
121
+ }
122
+ function _startEntryStaleTimer(entry, key) {
123
+ _clearEntryStaleTimer(entry);
124
+ if (cache?.staleTime != null && isFinite(cache.staleTime)) entry.staleTimer = setTimeout(() => {
125
+ if (!entry.stale) {
126
+ entry.stale = true;
127
+ _startEntryCacheTimer(entry, key);
128
+ if (entry === getActiveEntry()) {
129
+ currentStale = true;
130
+ staleNotify();
131
+ }
132
+ }
133
+ }, cache.staleTime);
134
+ }
135
+ function _clearEntryCacheTimer(entry) {
136
+ if (entry.cacheTimer != null) {
137
+ clearTimeout(entry.cacheTimer);
138
+ entry.cacheTimer = null;
139
+ }
140
+ }
141
+ function _startEntryCacheTimer(entry, key) {
142
+ _clearEntryCacheTimer(entry);
143
+ if (cache?.cacheTime != null && isFinite(cache.cacheTime)) entry.cacheTimer = setTimeout(() => {
144
+ if (entry === getActiveEntry()) {
145
+ entry.value = void 0;
146
+ entry.error = void 0;
147
+ entry.status = "idle";
148
+ entry.stale = false;
149
+ _clearEntryCacheTimer(entry);
150
+ syncFromActiveEntry();
151
+ } else entries.delete(key);
152
+ }, cache.cacheTime);
153
+ }
54
154
  function getReady() {
55
155
  if (!_ready) _ready = createCondition(() => currentStatus === "ready", (notify$1) => statusSignal.subscribe(() => notify$1()));
56
156
  return _ready;
@@ -67,34 +167,68 @@ function createResourceNode(id, name, loader, autoLoad = true) {
67
167
  if (!_stale) _stale = createCondition(() => currentStale, (notify$1) => staleSignal.subscribe(() => notify$1()));
68
168
  return _stale;
69
169
  }
70
- async function executeLoad(context) {
71
- currentStale = false;
72
- staleNotify();
73
- currentStatus = "pending";
74
- currentValue = void 0;
75
- currentError = void 0;
76
- notify();
170
+ function executeLoad(context) {
171
+ const key = resolveKey(context);
172
+ const prevActiveKey = _activeKey;
173
+ _activeKey = key;
174
+ let entry = entries.get(key);
175
+ if (!entry) {
176
+ entry = createEntry();
177
+ entries.set(key, entry);
178
+ }
179
+ _clearEntryCacheTimer(entry);
180
+ if (shouldDeduplicate && entry.inFlightPromise) {
181
+ if (key !== prevActiveKey) syncFromEntry(entry);
182
+ return entry.inFlightPromise;
183
+ }
77
184
  if (context !== void 0) lastContext = context;
78
185
  const loadContext = context ?? lastContext ?? {};
79
- try {
80
- const result = await Promise.resolve(loader(loadContext));
81
- currentValue = result;
82
- currentStatus = "ready";
83
- currentStale = false;
84
- notify();
85
- staleNotify();
86
- } catch (e) {
87
- currentError = e;
88
- currentStatus = "failed";
89
- currentStale = false;
90
- notify();
91
- staleNotify();
92
- }
186
+ entry.stale = false;
187
+ entry.status = "pending";
188
+ entry.value = void 0;
189
+ entry.error = void 0;
190
+ syncFromActiveEntry();
191
+ const promise = (async () => {
192
+ try {
193
+ const result = await Promise.resolve(loader(loadContext));
194
+ if (entries.get(key) !== entry) return;
195
+ entry.value = result;
196
+ entry.status = "ready";
197
+ entry.stale = false;
198
+ _clearEntryCacheTimer(entry);
199
+ if (entry === getActiveEntry()) syncFromEntry(entry);
200
+ staleNotify();
201
+ _startEntryStaleTimer(entry, key);
202
+ } catch (e) {
203
+ if (entries.get(key) !== entry) return;
204
+ entry.error = e;
205
+ entry.status = "failed";
206
+ entry.stale = false;
207
+ if (entry === getActiveEntry()) syncFromEntry(entry);
208
+ staleNotify();
209
+ } finally {
210
+ entry.inFlightPromise = null;
211
+ }
212
+ })();
213
+ entry.inFlightPromise = promise;
214
+ return promise;
93
215
  }
94
216
  function invalidate() {
95
- if (!currentStale) {
96
- currentStale = true;
97
- staleNotify();
217
+ const entry = getActiveEntry();
218
+ if (!entry.stale) {
219
+ entry.stale = true;
220
+ _startEntryCacheTimer(entry, _activeKey);
221
+ if (entry === getActiveEntry()) {
222
+ currentStale = true;
223
+ staleNotify();
224
+ }
225
+ }
226
+ }
227
+ function dispose() {
228
+ for (const entry of entries.values()) {
229
+ _clearEntryStaleTimer(entry);
230
+ _clearEntryCacheTimer(entry);
231
+ entry.inFlightPromise = null;
98
232
  }
99
233
  }
100
234
  const node = {
@@ -127,7 +261,8 @@ function createResourceNode(id, name, loader, autoLoad = true) {
127
261
  invalidate,
128
262
  subscribe(fn) {
129
263
  return statusSignal.subscribe(fn);
130
- }
264
+ },
265
+ dispose
131
266
  };
132
267
  return node;
133
268
  }
@@ -683,6 +818,7 @@ function screen(name, fn) {
683
818
  name: n,
684
819
  autoLoad: config.autoLoad ?? true,
685
820
  loader: config.load,
821
+ cache: config.cache,
686
822
  ref
687
823
  });
688
824
  return ref;
@@ -761,6 +897,38 @@ function computeDiagnostics(screenDef) {
761
897
  message: "Action is defined but not included in any surface.",
762
898
  nodeId: act.id
763
899
  });
900
+ if (screenDef.flows.length > 0) {
901
+ const flowNodeIds = /* @__PURE__ */ new Set();
902
+ for (const flow of screenDef.flows) for (const step of flow.steps) flowNodeIds.add(step.node.id);
903
+ for (const flow of screenDef.flows) for (const step of flow.steps) if (!surfacedNodeIds.has(step.node.id)) diagnostics.push({
904
+ severity: "warning",
905
+ code: "flow-step-not-surfaced",
906
+ message: `"${step.node.label}" is a flow step but not included in any surface.`,
907
+ nodeId: step.node.id,
908
+ flow: { flowNodeId: flow.id }
909
+ });
910
+ for (const flow of screenDef.flows) if (flow.steps.length > 0) {
911
+ const hasSurfacedStep = flow.steps.some((step) => surfacedNodeIds.has(step.node.id));
912
+ if (!hasSurfacedStep) diagnostics.push({
913
+ severity: "warning",
914
+ code: "orphaned-flow",
915
+ message: `"${flow.name}" has no surfaced steps.`,
916
+ flow: { flowNodeId: flow.id }
917
+ });
918
+ }
919
+ for (const ask of screenDef.asks) if (surfacedNodeIds.has(ask.id) && !flowNodeIds.has(ask.id)) diagnostics.push({
920
+ severity: "info",
921
+ code: "surfaced-node-not-in-any-flow",
922
+ message: `"${ask.label}" is surfaced but not referenced in any flow.`,
923
+ nodeId: ask.id
924
+ });
925
+ for (const act of screenDef.acts) if (surfacedNodeIds.has(act.id) && !flowNodeIds.has(act.id)) diagnostics.push({
926
+ severity: "info",
927
+ code: "surfaced-node-not-in-any-flow",
928
+ message: `"${act.label}" is surfaced but not referenced in any flow.`,
929
+ nodeId: act.id
930
+ });
931
+ }
764
932
  return diagnostics;
765
933
  }
766
934
  function inspectScreen(screenDef, runtimeResources) {
@@ -773,9 +941,15 @@ function inspectScreen(screenDef, runtimeResources) {
773
941
  const idToSemantic = /* @__PURE__ */ new Map();
774
942
  for (const a of screenDef.asks) idToSemantic.set(a.id, askIds(a.label));
775
943
  for (const a of screenDef.acts) idToSemantic.set(a.id, actIds(a.label));
944
+ const idToFlowSemantic = /* @__PURE__ */ new Map();
945
+ for (const f of screenDef.flows) idToFlowSemantic.set(f.id, flowIds(f.name));
776
946
  const augmentedDiagnostics = diagnostics.map((d) => ({
777
947
  ...d,
778
- semanticNodeId: d.nodeId ? idToSemantic.get(d.nodeId) : void 0
948
+ semanticNodeId: d.nodeId ? idToSemantic.get(d.nodeId) : void 0,
949
+ flow: d.flow ? {
950
+ ...d.flow,
951
+ flowSemanticNodeId: idToFlowSemantic.get(d.flow.flowNodeId)
952
+ } : void 0
779
953
  }));
780
954
  return {
781
955
  name: screenDef.name,
@@ -803,7 +977,7 @@ function inspectScreen(screenDef, runtimeResources) {
803
977
  })),
804
978
  flows: screenDef.flows.map((f) => ({
805
979
  id: f.id,
806
- semanticId: flowIds(f.name),
980
+ semanticId: idToFlowSemantic.get(f.id),
807
981
  name: f.name,
808
982
  stepCount: f.steps.length
809
983
  })),
@@ -869,7 +1043,7 @@ var ScreenRuntime = class {
869
1043
  this._started = true;
870
1044
  const nodeMap = /* @__PURE__ */ new Map();
871
1045
  for (const config of this._screen.resourceConfigs) {
872
- const node = createResourceNode(config.id, config.name, config.loader, false);
1046
+ const node = createResourceNode(config.id, config.name, config.loader, false, config.cache);
873
1047
  this._resourceNodes.push(node);
874
1048
  nodeMap.set(config.id, node);
875
1049
  }
@@ -891,6 +1065,7 @@ var ScreenRuntime = class {
891
1065
  this._disposed = true;
892
1066
  for (const unsub of this._unsubscribers) unsub();
893
1067
  this._unsubscribers = [];
1068
+ for (const node of this._resourceNodes) node.dispose();
894
1069
  for (const config of this._screen.resourceConfigs) if (config.ref && this._resourceNodeMap) {
895
1070
  const node = this._resourceNodeMap.get(config.id);
896
1071
  if (node) config.ref._disconnect(node);
@@ -1,6 +1,7 @@
1
1
  import { type Condition } from "./signal.js";
2
2
  import type { ActionExecutionContext, DefaultScreenServices } from "./act.js";
3
3
  export type ResourceStatus = "idle" | "pending" | "ready" | "failed";
4
+ export type ResourceKey = string | number | boolean | null | undefined | ResourceKey[];
4
5
  export type ResourceLoadContext<TServices extends object = DefaultScreenServices> = ActionExecutionContext<TServices>;
5
6
  type ResourceLoader<TValue, TServices extends object> = (() => TValue | Promise<TValue>) | ((context: ResourceLoadContext<TServices>) => TValue | Promise<TValue>);
6
7
  export type ResourceNode<TValue, TServices extends object = DefaultScreenServices> = {
@@ -18,17 +19,25 @@ export type ResourceNode<TValue, TServices extends object = DefaultScreenService
18
19
  reload: (context?: ResourceLoadContext<TServices>) => Promise<void>;
19
20
  invalidate: () => void;
20
21
  subscribe: (fn: () => void) => () => void;
22
+ dispose: () => void;
21
23
  };
22
24
  export type AnyResourceNode = ResourceNode<unknown, any>;
25
+ export type ResourceCacheOptions<TServices extends object = DefaultScreenServices> = {
26
+ key?: (context: ResourceLoadContext<TServices>) => ResourceKey;
27
+ staleTime?: number;
28
+ cacheTime?: number;
29
+ deduplicate?: boolean;
30
+ };
23
31
  export type ResourceConfig<TValue = unknown, TServices extends object = DefaultScreenServices> = {
24
32
  id: string;
25
33
  name: string;
26
34
  autoLoad: boolean;
27
35
  loader: ResourceLoader<TValue, TServices>;
36
+ cache?: ResourceCacheOptions<TServices>;
28
37
  ref?: ResourceRef<TValue, TServices>;
29
38
  };
30
39
  export declare function createResourceConfig<TValue, TServices extends object = DefaultScreenServices>(id: string, name: string, loader: ResourceLoader<TValue, TServices>, autoLoad?: boolean): ResourceConfig<TValue, TServices>;
31
- export declare function createResourceNode<TValue, TServices extends object = DefaultScreenServices>(id: string, name: string, loader: ResourceLoader<TValue, TServices>, autoLoad?: boolean): ResourceNode<TValue, TServices>;
40
+ export declare function createResourceNode<TValue, TServices extends object = DefaultScreenServices>(id: string, name: string, loader: ResourceLoader<TValue, TServices>, autoLoad?: boolean, cache?: ResourceCacheOptions<TServices>): ResourceNode<TValue, TServices>;
32
41
  export declare class ResourceRef<TValue, TServices extends object = DefaultScreenServices> {
33
42
  readonly id: string;
34
43
  readonly name: string;
package/dist/screen.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { AnyAskNode } from "./ask.js";
2
2
  import type { ActNode, DefaultScreenServices } from "./act.js";
3
3
  import type { FlowNode } from "./flow.js";
4
4
  import type { SurfaceNode } from "./surface.js";
5
- import type { ResourceConfig, ResourceLoadContext } from "./resource.js";
5
+ import type { ResourceCacheOptions, ResourceConfig, ResourceLoadContext } from "./resource.js";
6
6
  import { ResourceRef } from "./resource.js";
7
7
  import { type TextState, type BooleanState, type ChoiceState } from "./state.js";
8
8
  import { AskBuilder } from "./ask.js";
@@ -31,6 +31,7 @@ export type ScreenBuilder<TServices extends object = DefaultScreenServices> = {
31
31
  resource: <T>(name: string, config: {
32
32
  load: (() => Promise<T>) | ((context: ResourceLoadContext<TServices>) => Promise<T>);
33
33
  autoLoad?: boolean;
34
+ cache?: ResourceCacheOptions<TServices>;
34
35
  }) => ResourceRef<T, TServices>;
35
36
  };
36
37
  export type ScreenDefinition<TServices extends object = DefaultScreenServices> = {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.0-alpha.1",
6
+ "version": "0.1.0-alpha.10",
7
7
  "description": "Platformless semantic graph and runtime for Intent applications",
8
8
  "license": "MIT",
9
9
  "repository": {