diff --git a/docs/architect.md b/docs/architect.md
index 3987745..52477d8 100644
--- a/docs/architect.md
+++ b/docs/architect.md
@@ -52,7 +52,8 @@ Since we're have such complex systems for action validation, it's possible that
Delta Generators are single-responsibility components in the Architect Layer tasked with computing discrete state updates ("deltas") from validated intents.
### How They Function
-1. **Validation Prerequisite**: Delta generators execute *only* if the `LLMValidator` (or other validator layers) returns `isValid: true`.
+
+1. **Validation Prerequisite**: Delta generators execute _only_ if the `LLMValidator` (or other validator layers) returns `isValid: true`.
2. **Specialized Responsibility**: Each generator isolates a specific aspect of state transition (e.g., advancing the clock, updating positions, modifying attributes). This keeps prompts focused and avoids single monolithic LLM calls trying to update everything.
3. **Structured Outputs**: Generators query the LLM using Zod schemas to ensure type-safe and validated change deltas.
4. **Application and Persistence**: Once generated, the delta is applied to the live `WorldState` by deterministic code, and the changes are persisted to the database.
@@ -60,14 +61,15 @@ Delta Generators are single-responsibility components in the Architect Layer tas
### Core Generators
#### Time Delta Generator
+
Calculates the physical time duration (in minutes) that a validated action takes to complete:
-* **Inputs**: The validated action and the serialized objective `WorldState`.
-* **Output (Zod Schema)**:
+
+- **Inputs**: The validated action and the serialized objective `WorldState`.
+- **Output (Zod Schema)**:
```json
{
"minutesToAdvance": 25,
"explanation": "Searching a locked desk thoroughly takes time."
}
```
-* **Resolution**: The Architect advances `worldState.clock` by the returned minutes, then saves the updated world state to `SQLiteRepository`.
-
+- **Resolution**: The Architect advances `worldState.clock` by the returned minutes, then saves the updated world state to `SQLiteRepository`.
diff --git a/docs/intents.md b/docs/intents.md
new file mode 100644
index 0000000..ca4962c
--- /dev/null
+++ b/docs/intents.md
@@ -0,0 +1,87 @@
+# Intents
+
+The simple way of understanding intents is to think of it as a proposal, not an effect.
+
+I want to do X:
+
+- Declarative
+- High-level
+- Allowed to be wrong
+- Cheap to generate (LLM-friendly)
+
+But, the actor LLM doesn't directly generate an intent. In order to keep the narrative going, the actor agent simply generates the continuing prose. This keeps the tone of the story, and if the intents validate, the narrative prose will directly go to the user while the deltas generated from the intents modify the state.
+
+Another benefit of this architecture is that we can use a separate intent decoder to detect the type of intent (Dialogue Intent or Action Intent) or even separate multiple intents in a single prose and validate them. Post-validation they can be sent to the Scheduler to allow little voids where the entity can be interjected, etc (Exact mechanism is deferred).
+
+```mermaid
+flowchart LR
+ A[Actor Agent]
+ B[/Action Narrative
Prose/]
+ C{{Intent
Decoder}}
+ F[Architect]
+ E{{Intent
Scheduler
deferred for v0}}
+
+ A --> B
+ B --> C
+
+ subgraph D["Intent Sequence"]
+ direction TD
+ I1([Dialogue Intent])
+ I2([Action Intent])
+ I3([Action Intent])
+ I4([⋯])
+
+ I1 --> I2 --> I3 --> I4
+ end
+
+ C --> D
+ D --> |"Concurrent"| F
+ F --> E
+```
+
+## Intent Decoder
+
+The job of the Intent Decoder is to:
+
+- See if the narrative prose can be split into multiple intents.
+- Classify each intent to its type (`dialogue` or `action`).
+- Parse the narrative text into structured JSON with minimal information loss.
+- Contextually resolve the receiving parties/targets (for example, who is being spoken to, what object is being interacted with).
+
+### Zod Schemas & Types
+
+We define structured Zod schemas to validate types returned by the LLM:
+
+- **IntentType**: `"dialogue"` | `"action"`
+- **Intent**:
+ - `type`: `IntentType`
+ - `originalText`: `string` (the slice of raw prose text containing the intent)
+ - `description`: `string` (summarized intent action)
+ - `actorId`: `string`
+ - `targetIds`: `string[]` (resolved recipient or target entity IDs)
+- **IntentSequence**:
+ - `intents`: `Intent[]`
+
+### IntentDecoder Class
+
+The `IntentDecoder` uses an `ILLMProvider` to query the LLM:
+
+```typescript
+export class IntentDecoder {
+ constructor(private llmProvider: ILLMProvider) {}
+
+ async decode(
+ worldState: WorldState,
+ actorId: string,
+ narrativeProse: string,
+ ): Promise;
+}
+```
+
+It serializes the world state (via `worldState.serialize()`) and feeds all known entity IDs as context to the system and user prompts, enabling the model to resolve the target IDs correctly.
+
+## A Dilemma of Concurrent Actions (deferred)
+
+How would you deal with actions that are taking place at the same time? Since the decoder could split them into 2 different intents, and one passes the validators and the other doesn't, would it make sense to send that intent back to the actor agent? Wouldn't that create coherence issues?
+
+If we decide to batch actions together in a single intent then how would the structure change for that?
diff --git a/packages/intent/package.json b/packages/intent/package.json
index a070c5f..095cf86 100644
--- a/packages/intent/package.json
+++ b/packages/intent/package.json
@@ -5,5 +5,10 @@
"type": "module",
"exports": {
".": "./dist/index.js"
+ },
+ "dependencies": {
+ "@omnia/core": "workspace:*",
+ "@omnia/llm": "workspace:*",
+ "zod": "^4.4.3"
}
}
diff --git a/packages/intent/src/index.ts b/packages/intent/src/index.ts
index cb0ff5c..c9b24c4 100644
--- a/packages/intent/src/index.ts
+++ b/packages/intent/src/index.ts
@@ -1 +1,2 @@
-export {};
+export * from "./intent.js";
+export * from "./intent-decoder.js";
diff --git a/packages/intent/src/intent-decoder.ts b/packages/intent/src/intent-decoder.ts
new file mode 100644
index 0000000..7898fe1
--- /dev/null
+++ b/packages/intent/src/intent-decoder.ts
@@ -0,0 +1,73 @@
+import { WorldState } from "@omnia/core";
+import { ILLMProvider } from "@omnia/llm";
+import { IntentSequence, IntentSequenceSchema } from "./intent.js";
+
+export class IntentDecoder {
+ constructor(private llmProvider: ILLMProvider) {}
+
+ /**
+ * Decodes narrative prose into an ordered sequence of structured intents.
+ *
+ * Responsibilities (from docs/intents.md):
+ * - Split prose into multiple intents when applicable.
+ * - Classify each intent as "dialogue" or "action".
+ * - Parse narrative text into structured JSON with minimal information loss.
+ * - Contextually resolve receiving parties (targets).
+ */
+ async decode(
+ worldState: WorldState,
+ actorId: string,
+ narrativeProse: string,
+ ): Promise {
+ const entityIds = Array.from(worldState.entities.keys());
+
+ const systemPrompt = `
+You are the Intent Decoder for a narrative simulation engine.
+Your job is to take a block of narrative prose written by an actor agent and decompose it into an ordered sequence of discrete intents.
+
+For each intent you must:
+1. Classify its type:
+ - "dialogue": Any speech, conversation, or verbal communication directed at another entity.
+ - "action": Any physical or logical action performed in the world (e.g., moving, picking up, opening, looking).
+2. Extract the original text fragment from the prose that corresponds to this intent.
+3. Write a concise, structured description of the intent (what is being done or said). Include as much detail about the action as possible that was extracted from the narrative prose. Do not make up qualities.
+4. Identify the actorId (the entity performing the intent — this will always be "${actorId}").
+5. Identify targetIds — the entity IDs of the receiving parties. Use only IDs from the known entities list. If no specific target, use an empty array.
+
+Rules:
+- Preserve the chronological order of intents as they appear in the prose.
+- Do NOT merge unrelated actions into a single intent.
+- Dialogue and actions should be separate intents even if they happen in the same sentence.
+- If the prose contains only dialogue, return a single dialogue intent.
+- If the prose contains only a single action, return a single action intent.
+`.trim();
+
+ const userContext = `
+=== KNOWN ENTITY IDS ===
+${entityIds.length > 0 ? entityIds.join(", ") : "(No entities)"}
+
+=== WORLD STATE ===
+${worldState.serialize()}
+
+=== ACTOR ===
+Actor ID: ${actorId}
+
+=== NARRATIVE PROSE ===
+${narrativeProse}
+`.trim();
+
+ const response = await this.llmProvider.generateStructuredResponse({
+ systemPrompt,
+ userContext,
+ schema: IntentSequenceSchema,
+ });
+
+ if (!response.success || !response.data) {
+ throw new Error(
+ `Intent decoding failed: ${response.error || "Unknown LLM error"}`,
+ );
+ }
+
+ return response.data;
+ }
+}
diff --git a/packages/intent/src/intent.ts b/packages/intent/src/intent.ts
new file mode 100644
index 0000000..ee7a090
--- /dev/null
+++ b/packages/intent/src/intent.ts
@@ -0,0 +1,44 @@
+import { z } from "zod";
+
+/**
+ * Intent types as classified by the Intent Decoder.
+ * - "dialogue": Speech or conversation directed at another entity.
+ * - "action": A physical or logical action performed in the world.
+ */
+export const IntentTypeSchema = z.enum(["dialogue", "action"]);
+export type IntentType = z.infer;
+
+/**
+ * A single decoded intent extracted from narrative prose.
+ */
+export const IntentSchema = z.object({
+ /** The type of intent. */
+ type: IntentTypeSchema,
+
+ /** The original narrative text fragment this intent was extracted from. */
+ originalText: z.string(),
+
+ /** A concise, structured description of the intent's action or dialogue. */
+ description: z.string(),
+
+ /** The entity ID of the actor performing the intent. */
+ actorId: z.string(),
+
+ /**
+ * Entity IDs of the receiving parties (e.g., who is being spoken to,
+ * what object is being interacted with).
+ */
+ targetIds: z.array(z.string()),
+});
+
+export type Intent = z.infer;
+
+/**
+ * The full output of the Intent Decoder: an ordered sequence of intents
+ * extracted from a single narrative prose block.
+ */
+export const IntentSequenceSchema = z.object({
+ intents: z.array(IntentSchema),
+});
+
+export type IntentSequence = z.infer;
diff --git a/packages/intent/tests/intent.test.ts b/packages/intent/tests/intent.test.ts
new file mode 100644
index 0000000..3bc7af8
--- /dev/null
+++ b/packages/intent/tests/intent.test.ts
@@ -0,0 +1,123 @@
+import { describe, test, expect } from "vitest";
+import { WorldState, Entity } from "@omnia/core";
+import { MockLLMProvider } from "@omnia/llm";
+import { IntentDecoder, IntentSequence } from "@omnia/intent";
+
+describe("IntentDecoder Unit Tests (Tier 1)", () => {
+ test("decodes prose with a single action intent", async () => {
+ const world = new WorldState("world-1");
+ const alice = new Entity("alice");
+ world.addEntity(alice);
+
+ const mockResponse: IntentSequence = {
+ intents: [
+ {
+ type: "action",
+ originalText: "Alice opened the chest.",
+ description: "Open the wooden chest.",
+ actorId: "alice",
+ targetIds: [],
+ },
+ ],
+ };
+
+ const llm = new MockLLMProvider([mockResponse]);
+ const decoder = new IntentDecoder(llm);
+
+ const result = await decoder.decode(world, "alice", "Alice opened the chest.");
+
+ expect(result.intents).toHaveLength(1);
+ expect(result.intents[0].type).toBe("action");
+ expect(result.intents[0].actorId).toBe("alice");
+ expect(result.intents[0].targetIds).toEqual([]);
+ });
+
+ test("decodes prose with a single dialogue intent", async () => {
+ const world = new WorldState("world-1");
+ const alice = new Entity("alice");
+ const bob = new Entity("bob");
+ world.addEntity(alice);
+ world.addEntity(bob);
+
+ const mockResponse: IntentSequence = {
+ intents: [
+ {
+ type: "dialogue",
+ originalText: '"Do you have the key?" Alice asked Bob.',
+ description: "Alice asks Bob if he has the key.",
+ actorId: "alice",
+ targetIds: ["bob"],
+ },
+ ],
+ };
+
+ const llm = new MockLLMProvider([mockResponse]);
+ const decoder = new IntentDecoder(llm);
+
+ const result = await decoder.decode(
+ world,
+ "alice",
+ '"Do you have the key?" Alice asked Bob.',
+ );
+
+ expect(result.intents).toHaveLength(1);
+ expect(result.intents[0].type).toBe("dialogue");
+ expect(result.intents[0].targetIds).toEqual(["bob"]);
+ });
+
+ test("decodes prose with mixed dialogue and action intents", async () => {
+ const world = new WorldState("world-1");
+ const alice = new Entity("alice");
+ const bob = new Entity("bob");
+ world.addEntity(alice);
+ world.addEntity(bob);
+
+ const mockResponse: IntentSequence = {
+ intents: [
+ {
+ type: "dialogue",
+ originalText: '"Cover me," Alice whispered to Bob.',
+ description: "Alice whispers to Bob requesting cover.",
+ actorId: "alice",
+ targetIds: ["bob"],
+ },
+ {
+ type: "action",
+ originalText: "She crept towards the door and pulled the handle.",
+ description: "Creep towards the door and pull the handle.",
+ actorId: "alice",
+ targetIds: [],
+ },
+ ],
+ };
+
+ const llm = new MockLLMProvider([mockResponse]);
+ const decoder = new IntentDecoder(llm);
+
+ const result = await decoder.decode(
+ world,
+ "alice",
+ '"Cover me," Alice whispered to Bob. She crept towards the door and pulled the handle.',
+ );
+
+ expect(result.intents).toHaveLength(2);
+ expect(result.intents[0].type).toBe("dialogue");
+ expect(result.intents[0].targetIds).toEqual(["bob"]);
+ expect(result.intents[1].type).toBe("action");
+ expect(result.intents[1].actorId).toBe("alice");
+ });
+
+ test("throws on LLM failure", async () => {
+ const world = new WorldState("world-1");
+ const alice = new Entity("alice");
+ world.addEntity(alice);
+
+ // MockLLMProvider with empty queue will return { success: false }
+ const llm = new MockLLMProvider([]);
+ const decoder = new IntentDecoder(llm);
+
+ await expect(
+ decoder.decode(world, "alice", "Alice ran away."),
+ ).rejects.toThrow("Intent decoding failed");
+ });
+});
diff --git a/packages/intent/tsconfig.json b/packages/intent/tsconfig.json
index d3399c2..ecba433 100644
--- a/packages/intent/tsconfig.json
+++ b/packages/intent/tsconfig.json
@@ -5,5 +5,8 @@
"outDir": "dist"
},
"include": ["src"],
- "references": []
+ "references": [
+ { "path": "../core" },
+ { "path": "../llm" }
+ ]
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cd27dec..887c0d5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -321,7 +321,17 @@ importers:
specifier: ^12.11.1
version: 12.11.1
- packages/intent: {}
+ packages/intent:
+ dependencies:
+ '@omnia/core':
+ specifier: workspace:*
+ version: link:../core
+ '@omnia/llm':
+ specifier: workspace:*
+ version: link:../llm
+ zod:
+ specifier: ^4.4.3
+ version: 4.4.3
packages/llm:
dependencies:
diff --git a/vitest.config.evals.ts b/vitest.config.evals.ts
index 075169f..ac34003 100644
--- a/vitest.config.evals.ts
+++ b/vitest.config.evals.ts
@@ -1,5 +1,10 @@
import { defineConfig } from "vitest/config";
import dotenv from "dotenv";
+import path from "path";
+import { fileURLToPath } from "url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
// Load environment variables for evals at test process startup
dotenv.config();
@@ -8,5 +13,16 @@ export default defineConfig({
test: {
include: ["tests/evals/**/*.eval.ts"],
exclude: ["**/node_modules/**", "**/dist/**"]
+ },
+ resolve: {
+ alias: {
+ "@omnia/core": path.resolve(__dirname, "./packages/core/src"),
+ "@omnia/llm": path.resolve(__dirname, "./packages/llm/src"),
+ "@omnia/architect": path.resolve(__dirname, "./packages/architect/src"),
+ "@omnia/intent": path.resolve(__dirname, "./packages/intent/src"),
+ "@omnia/memory": path.resolve(__dirname, "./packages/memory/src"),
+ "@omnia/spatial": path.resolve(__dirname, "./packages/spatial/src"),
+ "@omnia/cli": path.resolve(__dirname, "./cli/src")
+ }
}
});
diff --git a/vitest.config.ts b/vitest.config.ts
index ca4e3ed..eea943d 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,4 +1,9 @@
import { defineConfig } from "vitest/config";
+import path from "path";
+import { fileURLToPath } from "url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
export default defineConfig({
test: {
@@ -7,5 +12,16 @@ export default defineConfig({
"**/dist/**",
"tests/evals/**"
]
+ },
+ resolve: {
+ alias: {
+ "@omnia/core": path.resolve(__dirname, "./packages/core/src"),
+ "@omnia/llm": path.resolve(__dirname, "./packages/llm/src"),
+ "@omnia/architect": path.resolve(__dirname, "./packages/architect/src"),
+ "@omnia/intent": path.resolve(__dirname, "./packages/intent/src"),
+ "@omnia/memory": path.resolve(__dirname, "./packages/memory/src"),
+ "@omnia/spatial": path.resolve(__dirname, "./packages/spatial/src"),
+ "@omnia/cli": path.resolve(__dirname, "./cli/src")
+ }
}
});