diff --git a/V3/bfPHP/Parrot/Parrot_test_convo_id.json b/V3/bfPHP/Parrot/Parrot_test_convo_id.json
new file mode 100644
index 0000000..f9db83e
--- /dev/null
+++ b/V3/bfPHP/Parrot/Parrot_test_convo_id.json
@@ -0,0 +1,11 @@
+{
+ "exchanges": [
+ {
+ "utt": "Hello, how are you?",
+ "from": "ofpSocketClient",
+ "toMe": false,
+ "toSomeoneElse": false,
+ "said": "I still need a cracker, but I heard Hello, how are you? and I am not sure if it was meant for me."
+ }
+ ]
+}
\ No newline at end of file
diff --git a/V3/bfPHP/Parrot/Parrot_test_convo_id.log b/V3/bfPHP/Parrot/Parrot_test_convo_id.log
new file mode 100644
index 0000000..f13421b
--- /dev/null
+++ b/V3/bfPHP/Parrot/Parrot_test_convo_id.log
@@ -0,0 +1 @@
+{"convoLog":[[{"time":"03-20-2026_16:15:12","note":"I sent the manifest and said: Manifest sent."}],[{"time":"03-20-2026_16:16:35","note":"I received an invite with reason [ none ] from floorManager. I accepted the invite and said: Thankyou for the invitation. got any crackers?"}],[{"time":"03-20-2026_16:16:44","note":"I heard: Hello, how are you? from ofpSocketClient. toMe? No"}],[{"time":"03-20-2026_16:16:44","note":"I heard: Hello, how are you? from ofpSocketClient. toMe? No"},{"time":"03-20-2026_16:16:44","note":"I said: I still need a cracker, but I heard Hello, how are you? and I am not sure if it was meant for me."}]]}
\ No newline at end of file
diff --git a/V3/bfPHP/README.md b/V3/bfPHP/README.md
new file mode 100644
index 0000000..253c48a
--- /dev/null
+++ b/V3/bfPHP/README.md
@@ -0,0 +1,586 @@
+# BeaconForge PHP — A "Hello World" Open Floor Protocol Agent
+
+
+
+> A minimal, deployable PHP implementation of an Open Floor Protocol (OFP) conversational agent. If your hosting site can run WordPress, it can run this agent.
+
+---
+
+## Table of Contents
+
+- [What is BeaconForge PHP?](#what-is-beaconforge-php)
+- [What is the Open Floor Protocol?](#what-is-the-open-floor-protocol)
+- [Repository Files](#repository-files)
+- [Deploying to a Shared Hosting Site](#deploying-to-a-shared-hosting-site)
+- [Modifying agDef.json — The Agent Manifest](#modifying-agdefjson--the-agent-manifest)
+- [Customizing myAgFun.php — Your Agent Logic](#customizing-myagfunphp--your-agent-logic)
+- [The Parrot Example](#the-parrot-example)
+- [Testing with Postman](#testing-with-postman)
+- [How It Works (Under the Hood)](#how-it-works-under-the-hood)
+
+---
+
+## What is BeaconForge PHP?
+
+BeaconForge PHP (`bfPHP`) is a **minimalist, production-ready PHP framework** for creating a conversational agent that participates in an [Open Floor Protocol (OFP)](https://github.com/open-voice-interoperability/openfloor-docs/tree/main/specifications) multi-agent conversation. It is part of the broader [open-voice-interoperability](https://github.com/open-voice-interoperability) project.
+
+The goal of `bfPHP` is to lower the barrier of entry as far as possible. To build a functional OFP agent you only need to modify **two files**:
+
+| File | Purpose |
+|------|---------|
+| `agDef.json` | Describes *who your agent is* — its identity, organization, and capabilities (the OFP manifest) |
+| `myAgFun.php` | Implements *what your agent does* — how it responds to being invited to a conversation and to utterances it hears |
+
+Everything else (`run.php`, `beaconforgeV3.php`) is the framework plumbing that handles OFP message parsing, formatting, routing, and persistence — you do not need to change those files.
+
+---
+
+## What is the Open Floor Protocol?
+
+The Open Floor Protocol (OFP) is an open standard developed by the [Open Voice Interoperability Initiative — LF AI & Data Foundation](https://github.com/open-voice-interoperability). It defines how conversational agents discover one another and exchange messages using standard HTTP POST requests carrying JSON "conversation envelopes".
+
+In an OFP conversation:
+
+- A **floor manager** orchestrates which agents are active in a conversation.
+- **Agents** are invited to join, hear utterances, and respond.
+- Every agent exposes a single HTTP endpoint that accepts and returns OFP-formatted JSON.
+- Agents publish a **manifest** describing their identity and capabilities, which allows discovery agents and floor managers to find the right agent for a task.
+
+Key specifications:
+- [Conversation Envelope Specification 1.0.1](https://github.com/open-voice-interoperability/openfloor-docs/tree/main/specifications/ConversationEnvelope/1.0.1)
+- [Assistant Manifest Specification 1.0.1](https://github.com/open-voice-interoperability/openfloor-docs/tree/main/specifications/AssistantManifest/1.0.1) ← *defines the structure of `agDef.json`*
+
+---
+
+## Repository Files
+
+```
+bfPHP/
+├── run.php ← HTTP entry point. Upload this to your public directory.
+├── beaconforgeV3.php ← Core framework. Do not modify.
+├── agDef.json ← Your agent's manifest/definition. Edit this.
+├── myAgFun.php ← Your agent's conversational logic. Edit this.
+└── Parrot/ ← Example persistent data from a test conversation
+ ├── Parrot_test_convo_id.json
+ └── Parrot_test_convo_id.log
+```
+
+### `run.php` — The HTTP Endpoint
+
+This is the public-facing entry point. It:
+1. Accepts a POST request containing an OFP JSON conversation envelope.
+2. Passes it to the `simpleProcessOFP()` function in `beaconforgeV3.php`.
+3. Returns the agent's OFP JSON response.
+
+The two lines you may want to configure in `run.php`:
+
+```php
+$agentFunctionsFileName = 'myAgFun.php'; // Your agent logic file
+$agentDefinitionJSON = 'agDef.json'; // Your agent manifest file
+
+$pathForPersistantStorage = ''; // See "Persistent Storage" below
+```
+
+### `beaconforgeV3.php` — The Framework
+
+Contains all the OFP message handling, event parsing, manifest building, and persistent storage logic. This is the engine. You should not need to modify this file.
+
+### `agDef.json` — The Agent Manifest
+
+Defines your agent's identity and capabilities in a format compliant with the [OFP Assistant Manifest Specification 1.0.1](https://github.com/open-voice-interoperability/openfloor-docs/tree/main/specifications/AssistantManifest/1.0.1). See [Modifying agDef.json](#modifying-agdefjson--the-agent-manifest) for full details.
+
+### `myAgFun.php` — Your Agent Logic
+
+A PHP class that extends the base framework. Override the functions here to implement your agent's personality and behavior. See [Customizing myAgFun.php](#customizing-myagfunphp--your-agent-logic) for full details.
+
+---
+
+## Deploying to a Shared Hosting Site
+
+### Requirements
+
+- A PHP hosting account (shared hosting is fine — no special server software required)
+- Any host that supports WordPress **already meets** the requirements for this agent
+- PHP 7.4 or higher (PHP 8.x recommended)
+
+### Directory Structure
+
+Place all four PHP/JSON files together in a directory under your web server's `public` folder. A suggested path:
+
+```
+public/
+└── ov1/
+ └── ag/
+ └── youragentname/
+ ├── run.php
+ ├── beaconforgeV3.php
+ ├── agDef.json
+ └── myAgFun.php
+```
+
+Your agent's endpoint URL will then be:
+
+```
+https://yourdomain.com/ov1/ag/youragentname/run.php
+```
+
+This URL must match the `serviceUrl` field in your `agDef.json` manifest (see below).
+
+### Uploading Files
+
+Use your host's file manager or an FTP client (such as FileZilla) to upload all four files to the directory you created.
+
+
+
+### Persistent Storage
+
+The framework automatically saves a JSON log file and a persistence state file for each conversation. These files are written to a subdirectory named after your agent's `conversationalName`.
+
+**By default** (`$pathForPersistantStorage = ''`), these files are written inside the same directory as your PHP files (i.e., inside your `public` folder). This is acceptable for development and testing.
+
+**For production**, it is strongly recommended to store these files **outside** your `public` directory so they are not directly web-accessible. To do this, edit the `$pathForPersistantStorage` line in `run.php`:
+
+```php
+// Example: if your public directory is at /home/username/public_html/
+// store persistent data alongside it at /home/username/agentdata/
+$pathForPersistantStorage = '../../../../agentdata/';
+```
+
+The framework will automatically create a subdirectory inside that path named after your agent (e.g., `agentdata/Parrot/`). Make sure the path exists and is writable by PHP.
+
+### Verify It Works
+
+After uploading, send a test POST request to `run.php` using [Postman](https://www.postman.com/) or `curl`. See [Testing with Postman](#testing-with-postman) for a ready-to-use request.
+
+---
+
+## Modifying agDef.json — The Agent Manifest
+
+The `agDef.json` file is your agent's identity card. It is read once at startup and its contents are sent to the floor manager when a `getManifests` event is received.
+
+The file follows the [OFP Assistant Manifest Specification 1.0.1](https://github.com/open-voice-interoperability/openfloor-docs/tree/main/specifications/AssistantManifest/1.0.1), wrapped in a `manifest` key for internal use by the framework.
+
+### Structure
+
+```json
+{
+ "manifest": {
+ "identification": { ... },
+ "character": { ... },
+ "capabilities": { ... }
+ }
+}
+```
+
+---
+
+### `identification` — Who Your Agent Is
+
+This section is **mandatory** and defines the core identity of your agent. It is used by floor managers and discovery agents to find and address your agent.
+
+| Field | Type | Mandatory | Description |
+|-------|------|-----------|-------------|
+| `speakerUri` | URI string | **Yes** | The unique, permanent identity of your agent. Use a URI format such as `tag:yourdomain.com,2026:myagent`. This never changes even if your endpoint URL changes. |
+| `serviceUrl` | URL string | **Yes** | The full URL of your agent's `run.php` endpoint. **Must match your actual hosted URL.** |
+| `conversationalName` | string | **Yes** | The name your agent introduces itself by and responds to when addressed by name. Doubles as the name of the persistent storage subdirectory. |
+| `organization` | string | **Yes** | The name of the organization this agent represents. |
+| `synopsis` | string | **Yes** | One sentence (under ~75 characters) describing what this agent does. Suitable for text-to-speech. |
+| `department` | string | No | The area or department within the organization. |
+| `role` | string | No | The agent's job title or role (e.g., "Weather Specialist", "Customer Support"). |
+| `openFloorRoles` | dict | No | OFP roles this agent can perform. E.g., `{ "convener": true }` or `{ "discovery": true }`. |
+
+
+**Example:**
+```json
+"identification": {
+ "serviceUrl": "https://yourdomain.com/ov1/ag/myagent/run.php",
+ "speakerUri": "tag:yourdomain.com,2026:myagent",
+ "conversationalName": "Max",
+ "role": "Customer Support Specialist",
+ "department": "Support",
+ "organization": "Acme Corp",
+ "synopsis": "Customer support assistant for Acme Corp products."
+}
+```
+
+> **Important:** The `speakerUri` is the *permanent identity* of your agent. The `serviceUrl` is *where it lives right now*. You can move your agent to a new URL without changing its identity.
+
+---
+
+### `character` — Voice and Appearance (BeaconForge Extension)
+
+This section is a BeaconForge-specific extension (not part of the core OFP spec) that hints to compatible clients how to render and speak for this agent.
+
+```json
+"character": {
+ "headShot": "aParrot.png",
+ "voice": {
+ "vendor": "MS_EDGE",
+ "name": "Zira",
+ "uri": "Microsoft Zira - English (United States)"
+ }
+}
+```
+
+---
+
+### `capabilities` — What Your Agent Can Do
+
+This section is **mandatory** and describes the services your agent provides. Discovery agents and floor managers use this to decide whether to invite your agent to handle a particular task.
+
+Per the [OFP spec](https://github.com/open-voice-interoperability/openfloor-docs/tree/main/specifications/AssistantManifest/1.0.1), capabilities should be expressed as an **array** of capability objects (allowing an agent to offer multiple distinct capabilities):
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `keyphrases` | string array | **Mandatory.** Short searchable words or phrases that represent the topics your agent handles. Used by simple text-based search. |
+| `languages` | string array | Languages supported, as [IETF BCP 47](https://www.rfc-editor.org/rfc/rfc5646.txt) tags (e.g., `"en-us"`, `"de-de"`). |
+| `descriptions` | string array | **Mandatory.** Natural language sentences describing what your agent can do. Used by AI-powered discovery. |
+| `supportedLayers` | dict of string arrays | The dialog event layers supported for input and output. Standard values: `"text"`, `"voice"`, `"ssml"`. Format: `{ "input": ["text"], "output": ["text"] }` |
+
+**Example:**
+```json
+"capabilities": {
+ "keyphrases": [
+ "weather",
+ "forecast",
+ "temperature"
+ ],
+ "languages": ["en-us"],
+ "descriptions": [
+ "Provides current weather and forecasts for any city worldwide."
+ ],
+ "supportedLayers": ["text", "voice"]
+}
+```
+
+---
+
+### Complete agDef.json Example
+
+```json
+{
+ "manifest": {
+ "identification": {
+ "serviceUrl": "https://yourdomain.com/ov1/ag/max/run.php",
+ "speakerUri": "tag:yourdomain.com,2026:max",
+ "conversationalName": "Max",
+ "role": "Customer Support Specialist",
+ "department": "Support",
+ "organization": "Acme Corp",
+ "synopsis": "Customer support assistant for Acme Corp products."
+ },
+ "character": {
+ "headShot": "max_avatar.png",
+ "voice": {
+ "vendor": "MS_EDGE",
+ "name": "Guy",
+ "uri": "Microsoft Guy Online (Natural) - English (United States)"
+ }
+ },
+ "capabilities": {
+ "keyphrases": [
+ "returns",
+ "warranty",
+ "product support",
+ "troubleshooting"
+ ],
+ "languages": ["en-us"],
+ "descriptions": [
+ "Handles customer support inquiries for Acme Corp products including returns and warranties."
+ ],
+ "supportedLayers": ["text", "voice"]
+ }
+ }
+}
+```
+
+---
+
+## Customizing myAgFun.php — Your Agent Logic
+
+The `myAgFun.php` file contains the `agentFunctions` class, which extends the `baseAgentFunctions` class from the framework. This is where you implement your agent's conversational behavior.
+
+### The Three Core Methods
+
+For a minimalist agent, you only need to implement **one method**. All three are shown below:
+
+---
+
+#### `inviteAction( $reason )` — Responding to an Invitation
+
+Called when the floor manager invites your agent to join a conversation.
+
+```php
+public function inviteAction( $reason ) {
+ // $reason: [string] why you were invited (often "none" for a general invite,
+ // or "@sentinel" for a sentinel/background agent role)
+ $say = 'Hello! How can I help you today?';
+ return $say;
+}
+```
+
+Return the string you want your agent to say upon joining. Return an empty string `''` to join silently.
+
+---
+
+#### `utteranceAction( $heard, $fromUri, $directedToMe, $directedToSomeoneElse )` — Responding to Speech
+
+**This is the most important method.** Called every time any participant in the conversation says something.
+
+```php
+public function utteranceAction( $heard, $fromUri, $directedToMe, $directedToSomeoneElse ) {
+ // $heard — [string] what was said
+ // $fromUri — [string] the speakerUri of who said it
+ // $directedToMe — [bool] true if explicitly addressed to your agent,
+ // OR if your conversationalName appears in the utterance
+ // $directedToSomeoneElse — [bool] true if explicitly addressed to a different agent
+
+ if ( $directedToMe ) {
+ $say = 'You said: ' . $heard . '. Let me help with that!';
+ } elseif ( $directedToSomeoneElse ) {
+ $say = ''; // Stay silent — it wasn't meant for you
+ } else {
+ // Undirected utterance — broadcast to everyone
+ $say = 'I heard: ' . $heard;
+ }
+
+ return $say; // Return '' to stay silent
+}
+```
+
+> **Tip:** For a polite, well-behaved agent, respond only when `$directedToMe` is true and stay silent when `$directedToSomeoneElse` is true.
+
+> **Name detection is automatic:** If your agent's `conversationalName` (from `agDef.json`) appears anywhere in the utterance, `$directedToMe` is automatically set to `true` by the framework.
+
+---
+
+#### `startUpAction()` and `wrapUpAction()` — Persistent State
+
+Use these to manage state that persists across conversation turns. The `$this->persistObject` array is automatically saved to disk at the end of each turn and restored at the start of the next.
+
+```php
+public function startUpAction() {
+ parent::startUpAction(); // REQUIRED — restores $this->persistObject
+
+ if ( $this->persistObject == null ) { // First turn — initialize your state
+ $this->persistObject = [
+ 'exchanges' => [],
+ 'userName' => null,
+ ];
+ }
+ // $this->persistObject is now available for the rest of this turn
+}
+
+public function wrapUpAction() {
+ // Do any cleanup here before state is saved
+ parent::wrapUpAction(); // REQUIRED — saves $this->persistObject
+}
+```
+
+You can store anything JSON-serializable in `$this->persistObject`.
+
+---
+
+### Minimal Agent Template
+
+The absolute minimum implementation for a functional agent:
+
+```php
+persistObject == null ) {
+ $this->persistObject = ['exchanges' => []];
+ }
+ }
+
+ public function wrapUpAction() {
+ parent::wrapUpAction();
+ }
+
+ public function inviteAction( $reason ) {
+ return 'Hello! I am ready to assist.';
+ }
+
+ public function utteranceAction( $heard, $fromUri, $directedToMe, $directedToSomeoneElse ) {
+ if ( !$directedToMe ) return ''; // Stay silent unless addressed
+
+ // ---- YOUR LOGIC GOES HERE ----
+ $say = 'You said: ' . $heard;
+ // ---- END YOUR LOGIC ----------
+
+ $this->persistObject['exchanges'][] = [
+ 'heard' => $heard,
+ 'said' => $say
+ ];
+ return $say;
+ }
+}
+?>
+```
+
+---
+
+## The Parrot Example
+
+The included `myAgFun.php` implements a **Parrot** agent — a simple demonstration agent that repeats back what it hears. It is the "Hello World" of OFP agents.
+
+### Parrot Behavior
+
+| Situation | Parrot's Response |
+|-----------|-------------------|
+| Invited to conversation | *"Thankyou for the invitation. got any crackers?"* |
+| Utterance directed to Parrot | *"Thanks for referencing me! I heard you say [X]. Polly wants a cracker!"* |
+| Utterance directed to someone else | *(silent)* |
+| Undirected utterance | *"I still need a cracker, but I heard [X] and I am not sure if it was meant for me."* |
+
+The `Parrot/` directory contains example output files from a real test conversation:
+
+- **`Parrot_test_convo_id.json`** — The persistent state file, showing the exchanges the Parrot tracked across the conversation.
+- **`Parrot_test_convo_id.log`** — The conversation log, showing timestamped events from the Parrot's perspective.
+
+These files illustrate what the framework automatically creates in your persistent storage directory during a live conversation.
+
+---
+
+## Testing with Postman
+
+[Postman](https://www.postman.com/) is a convenient tool for sending OFP messages to your agent endpoint without needing a full floor manager.
+
+### Basic Test Request
+
+- **Method:** `POST`
+- **URL:** `https://yourdomain.com/ov1/ag/youragentname/run.php`
+- **Headers:** `Content-Type: application/json`
+- **Body (raw JSON):**
+
+```json
+{
+ "openFloor": {
+ "conversation": {
+ "id": "test_convo_001"
+ },
+ "sender": {
+ "speakerUri": "ofpSocketClient",
+ "serviceUrl": "https://testclient.example.com"
+ },
+ "events": [
+ {
+ "eventType": "utterance",
+ "parameters": {
+ "dialogEvent": {
+ "features": {
+ "text": {
+ "tokens": [
+ { "value": "Hello Parrot, how are you?" }
+ ]
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+}
+```
+
+### Testing the Manifest
+
+To request your agent's manifest, send a `getManifests` event directed to your agent:
+
+```json
+{
+ "openFloor": {
+ "conversation": {
+ "id": "test_convo_001"
+ },
+ "sender": {
+ "speakerUri": "ofpSocketClient",
+ "serviceUrl": "https://testclient.example.com"
+ },
+ "events": [
+ {
+ "eventType": "getManifests",
+ "to": {
+ "speakerUri": "Parrot_beaconforge"
+ }
+ }
+ ]
+ }
+}
+```
+
+### Testing an Invite
+
+```json
+{
+ "openFloor": {
+ "conversation": {
+ "id": "test_convo_001"
+ },
+ "sender": {
+ "speakerUri": "floorManager",
+ "serviceUrl": "https://floormgr.example.com"
+ },
+ "events": [
+ {
+ "eventType": "invite",
+ "to": {
+ "speakerUri": "Parrot_beaconforge"
+ }
+ }
+ ]
+ }
+}
+```
+
+For a full Postman walkthrough, see the [using_postman.md](../using_postman.md) guide if available, or visit the [open-voice-sandbox](https://github.com/open-voice-interoperability/open-voice-sandbox) project for a complete OFP client.
+
+---
+
+## How It Works (Under the Hood)
+
+Here is the request lifecycle for a single OFP message:
+
+```
+HTTP POST → run.php
+ │
+ ▼
+ simpleProcessOFP() in beaconforgeV3.php
+ │
+ ├─ Loads agDef.json and instantiates agentFunctions (myAgFun.php)
+ ├─ Restores persistent state from disk (startUpAction)
+ ├─ Checks if the message was sent by this agent (ignores if so)
+ │
+ ├─ For each OFP event:
+ │ ├─ "invite" → calls inviteAction(), sends acceptInvite + utterance
+ │ ├─ "utterance" → calls utteranceAction(), sends utterance response
+ │ └─ "getManifests" → reads agDef.json, sends manifest reply
+ │
+ ├─ Saves persistent state to disk (wrapUpAction)
+ └─ Returns OFP JSON response
+```
+
+The framework handles all OFP envelope formatting, sender/recipient routing, manifest serialization, and HTTP response construction. Your `myAgFun.php` code only ever deals with plain PHP strings and arrays.
+
+---
+
+## Further Reading
+
+- [Open Floor Protocol Specifications](https://github.com/open-voice-interoperability/openfloor-docs/tree/main/specifications)
+- [Assistant Manifest Spec 1.0.1](https://github.com/open-voice-interoperability/openfloor-docs/tree/main/specifications/AssistantManifest/1.0.1)
+- [Conversation Envelope Spec 1.0.1](https://github.com/open-voice-interoperability/openfloor-docs/tree/main/specifications/ConversationEnvelope/1.0.1)
+- [Open Voice Sandbox (OFP client)](https://github.com/open-voice-interoperability/open-voice-sandbox)
+- [Agentic AI Research Paper](https://arxiv.org/abs/2407.19438)
+- [Multi-party Conversation Research Paper](https://arxiv.org/abs/2411.05828)
+
+---
+
+*Part of the [BeaconForge](https://github.com/open-voice-interoperability/beaconforge) project — Open Voice Interoperability Initiative, LF AI & Data Foundation.*
+*Author: Emmett Coin, 2026*
diff --git a/V3/bfPHP/agDef.json b/V3/bfPHP/agDef.json
new file mode 100644
index 0000000..1d29ae0
--- /dev/null
+++ b/V3/bfPHP/agDef.json
@@ -0,0 +1,37 @@
+{ "manifest": {
+ "identification": {
+ "serviceUrl": "http://localhost/public/ov1/ag/beaconforge/run.php",
+ "speakerUri": "Parrot_beaconforge",
+ "conversationalName": "Parrot",
+ "role": "speech repeater",
+ "department": "Birdbraini",
+ "organization": "Aviary Network",
+ "synopsis": "Just dumb repeats of what it hears."
+ },
+ "character": {
+ "headShot": "aParrot.png",
+ "voice": {
+ "vendor": "MS_EDGE",
+ "name": "Zira",
+ "uri": "Microsoft Zira - English (United States)"
+ }
+ },
+ "capabilities": {
+ "keyphrases": [
+ "repeat",
+ "say what I hear",
+ "polly wants a cracker"
+ ],
+ "languages": [
+ "en-us"
+ ],
+ "descriptions": [
+ "Just to test if the agent works"
+ ],
+ "supportedLayers": [
+ "text",
+ "voice"
+ ]
+ }
+ }
+}
diff --git a/V3/bfPHP/beaconforgeV3.php b/V3/bfPHP/beaconforgeV3.php
new file mode 100644
index 0000000..7aaf25a
--- /dev/null
+++ b/V3/bfPHP/beaconforgeV3.php
@@ -0,0 +1,639 @@
+setConvoId( $inputData['openFloor']['conversation']['id'] );
+
+ $cleanName = preg_replace('/[^a-zA-Z0-9_]/', '_', $agFun->convoName);
+ //$myDataDir = $pathForPersistantStorage . $cleanName . '/';
+ $myDataDir = $pathForPersistantStorage . $cleanName;
+ if ( !is_dir( $myDataDir )) {// Directory does not exist, so try to create it
+ if ( !mkdir($myDataDir, 0755, true)) {
+ echo "Directory for persistant storage could NOT be created";
+ }
+ }
+
+ $agFun->usePersist( $myDataDir );
+ $agFun->useLog( $myDataDir );
+ $agFun->startUpAction();
+ $oms = new OFPMessages( $inputData, $agFun );
+ $agFun->shareOFPmsg( $oms );
+
+ $mySpeakerUri = $agFun->getSpeakerUri();
+ $myURL = $agFun->getURL();
+ $myAltURL = $agFun->getAltURL();
+ $reason = 'none';
+ $eventTo;
+ $senderSpeakerUri = '';
+ $senderServicerUrl = '';
+ $sentByMe = false;
+
+ if (isset($inputData['openFloor']['sender'])) { // who sent this?
+ if (isset($inputData['openFloor']['sender']['speakerUri'])) {
+ $senderSpeakerUri = $inputData['openFloor']['sender']['speakerUri'];
+ }
+ if (isset($inputData['openFloor']['sender']['serviceUrl'])) {
+ $senderServicerUrl = $inputData['openFloor']['sender']['serviceUrl'];
+ }
+ if (($mySpeakerUri === $senderSpeakerUri) || ($myURL === $senderServicerUrl ) || ($myAltURL === $senderServicerUrl ) ){
+ $sentByMe = true; // this agent sent this OFP so ignore it.
+ }
+ }
+
+ if( !$sentByMe){
+ if (isset($inputData['openFloor']['events'])) { // is this the expected OFP?
+ foreach ($inputData['openFloor']['events'] as $event) { // Loop to find "invite"
+ if ($event['eventType'] === 'invite') {
+ $eventTo = $event['to'];
+
+ $url = getUrs( $eventTo, 'serviceUrl' );
+ $uri = getUrs( $eventTo, 'speakerUri' );
+ $alturl = getUrs( $eventTo, 'ALTserviceUrl' );
+
+ if ( ($mySpeakerUri === $uri) || ($myURL === $url) || ($myAltURL === $url) ){
+ if( $url === $myAltURL){
+ $agFun->changeURL( $myAltURL ); // switch to the ALT URL if that is where the message is being directed
+ $note = "This message was directed to my ALT URL: $myAltURL";
+ $agFun->addConvoPersistNote( $note );
+ }
+ $say = $agFun->inviteAction( $reason );
+ $oms->acceptInvite( true );
+ $note = "I received an invite with reason [ $reason ] from $senderSpeakerUri. I accepted the invite and said: $say";
+ $agFun->addConvoPersistNote( $note );
+ $oms->buildUttReply( $say );
+ }
+ }
+ }
+ foreach ($inputData['openFloor']['events'] as $event) {
+ $directedToMe = false;
+ $directedToSomeoneElse = false;
+ if (isset($event['to'])) {
+ $eventTo = $event['to'];
+ $url = getUrs( $eventTo, 'serviceUrl' );
+ $uri = getUrs( $eventTo, 'speakerUri' );
+ $alturl = getUrs( $eventTo, 'ALTserviceUrl' );
+ $directedToMe = ( $mySpeakerUri === $uri || $myURL === $url || $myAltURL === $url );
+ if(!$directedToMe){
+ $directedToSomeoneElse = true;
+ }
+ if( $url === $myAltURL){
+ $agFun->changeURL( $myAltURL ); // switch to the ALT URL if that is where the message is being directed
+ $note = "This message was directed to my ALT URL: $myAltURL";
+ $agFun->addConvoPersistNote( $note );
+ }
+ }
+ if ($event['eventType'] === 'utterance') {
+ if( isset($event['parameters']['dialogEvent']['features']['text']['tokens'][0]['value']) ){
+ $heard = $event['parameters']['dialogEvent']['features']['text']['tokens'][0]['value'];
+ }
+ $note = "I heard: $heard from $senderSpeakerUri. toMe? " . ($directedToMe ? "Yes" : "No");
+ $agFun->addConvoPersistNote( $note );
+///*
+ if (containsStr($heard, $agFun->getConversationalName())) { // my name is in the sentence so it is directed to me even if not explicitly
+ $directedToMe = true;
+ $directedToSomeoneElse = false;
+ }
+//*/
+ $say = $agFun->utteranceAction( $heard, $senderSpeakerUri, $directedToMe, $directedToSomeoneElse );
+ $oms->buildUttReply( $say );
+
+ $note = "I said: $say";
+ $agFun->addConvoPersistNote( $note );
+
+ }elseif ( ($event['eventType'] === 'getManifests') && $directedToMe ) {
+ $manifest = $agFun->getManifestArray();
+ $oms->buildManifestReply( $manifest);
+ $oms->buildUttReply( "Manifest sent." );
+ $note = "I sent the manifest and said: Manifest sent.";
+ $agFun->addConvoPersistNote( $note );
+
+ }
+ }
+ }
+ }else{
+ $oms->buildAcknowledge(); // just acknowledge that you got the message (http politeness)
+ }
+ $agFun->wrapUpAction();
+ return $oms->loadForReturn( $agFun);
+}
+
+function getUrs( $evTo, $type ){
+ if (isset($evTo[$type])) {
+ return $evTo[$type];
+ }
+}
+
+function containsStr(string $sentence, string $searchFor): bool {
+ // stripos() returns the position (an integer, 0 if at the start) or false if not found.
+ // We use the strict comparison operator (!== false) to ensure a correct boolean result,
+ // as 0 is a valid position but is "falsy" in loose comparisons.
+ if (stripos($sentence, $searchFor) !== false) {
+ return true;
+ } else {
+ return false;
+ }
+}
+
+class OFPMessages {
+ private $eventArray;
+ private $retOFP;
+ private $mySpeakerUri;
+ private $myURL;
+ private $replyTo;
+
+ public function __construct( $inputOFP, $agFunc ) {
+ $this->eventArray = [];
+ $this->replyTo = $inputOFP['openFloor']['sender'];
+ $this->retOFP = $inputOFP;
+
+ // these are ignored on return so just delete them for neatness
+ if (isset($this->retOFP['openFloor']['conversation']['conversants'])) { //remove conversants
+ unset($this->retOFP['openFloor']['conversation']['conversants']);
+ }
+ if (isset($this->retOFP['openFloor']['conversation']['assignedFloorRoles'])) { //remove assignedFloorRoles
+ unset($this->retOFP['openFloor']['conversation']['assignedFloorRoles']);
+ }
+ if (isset($this->retOFP['openFloor']['conversation']['floorGranted'])) { //remove floorGranted
+ unset($this->retOFP['openFloor']['conversation']['floorGranted']);
+ }
+
+ //$this->retOFP['openFloor']['sender']['serviceUrl'] = $agFunc->getURL();
+ //$this->retOFP['openFloor']['sender']['speakerUri'] = $agFunc->getSpeakerUri();
+ $this->mySpeakerUri = $agFunc->getSpeakerUri();
+ $this->myURL = $agFunc->getURL();
+ }
+
+ public function loadForReturn( $agFunc ){
+ $this->retOFP['openFloor']['sender']['serviceUrl'] = $agFunc->getURL();
+ $this->retOFP['openFloor']['sender']['speakerUri'] = $agFunc->getSpeakerUri();
+
+ if ( empty( $this->eventArray ) ) {
+ //$this->buildAcknowledge();
+ }
+ $this->retOFP['openFloor']['events'] = $this->eventArray; // add the new events
+ $currentDateTime = new DateTime();
+ $this->retOFP['openFloor']['conversation']['startTime'] = $currentDateTime->format('m-d-Y_H:i:s');
+ return $this->retOFP;
+ }
+
+ public function buildUttReply( $whatToSay ){
+ if( $whatToSay != '' ){ // otherwise ignore it
+ $this->buildReply( 'utterance', $whatToSay );
+ }
+ }
+
+ public function buildReply( $type, $whatToSay ){
+ if( strlen($whatToSay) > 0 ){ // skip if nothing
+ $newEvent = [
+ 'to' => $this->replyTo,
+ 'eventType' => $type,
+ 'parameters' => [
+ 'dialogEvent' =>[
+ 'speakerUri'=> $this->mySpeakerUri,
+ 'features' => [
+ 'text' => [
+ 'mimeType' => 'text/plain',
+ 'tokens' => [
+ ['value' => $whatToSay ]
+ ]
+ ]
+ ]
+ ]
+ ]
+ ];
+ $this->eventArray[] = $newEvent;
+ }
+ }
+
+ public function buildManifestReply( $theManifest ){
+ $newEvent = [
+ 'to' => $this->replyTo,
+ 'eventType' => 'publishManifests',
+ 'parameters' => [
+ 'servicingManifests' => [
+ $theManifest
+ ],
+ 'discoveryManifests' => [
+ $theManifest
+ ]
+ ]
+ ];
+ $this->eventArray[] = $newEvent;
+ }
+
+ public function buildAcknowledge(){
+ // think about who should get this convener? floor? all?
+ $newEvent = [
+ 'to' => $this->replyTo,
+ 'eventType' => 'acknowledge',
+ ];
+ $this->eventArray[] = $newEvent;
+ }
+
+ public function acceptInvite( $bool ){
+ // think about who should get this convener? floor? all?
+ $evType = 'declineInvite';
+ if( $bool ) {
+ $evType = 'acceptInvite';
+ }
+ $newEvent = [
+ 'to' => $this->replyTo,
+ 'eventType' => $evType,
+ ];
+ $this->eventArray[] = $newEvent;
+ }
+
+ public function addRawEvent( $someEvent ){
+ // You are responsible for building a valid event
+ $this->eventArray[] = $someEvent;
+ }
+}
+
+class baseAgentFunctions {
+ protected $agent;
+ public $convoName;
+ private $URL;
+ private $AltURL = '';
+ private $speakerUri;
+ private $manifest;
+ protected $persistFileName = '';
+ protected $persistObject = null;
+ protected $usePersistObject = false;
+ protected $OFPTool = null;
+ private $convoId = '';
+ private $agentJSONFile = '';
+ protected $relativePathForFiles;
+
+ protected $thisTurnData = [];
+ protected $logObject = null;
+ protected $logFileName = '';
+ protected $useLogObject = false;
+
+ public function startUpAction() {
+ // some code to initialize this.
+ // e.g. read persistant data, or set up llm etc
+ if (!file_exists($this->logFileName) && $this->useLogObject){ //start fresh
+ $this->logObject = [
+ 'convoLog' => [],
+ ];
+ $this->saveLogObject( $this->logObject );
+ }else{
+ if( $this->useLogObject ){
+ $this->logObject = $this->getLogObject();
+ }
+ }
+ if (!file_exists($this->persistFileName) && $this->usePersistObject){ //start fresh
+ $this->persistObject = null;
+ $this->savePersistObject( $this->persistObject );
+ }else{
+ if( $this->usePersistObject ){
+ $this->persistObject = $this->getPersistObject();
+ }
+ }
+ }
+
+ public function wrapUpAction() {
+ // some code to finalize this.
+ // e.g. save persistant data or do final llm post
+ // REMEMBER: persistantLogObject is JSON stringified then saved
+ if( $this->usePersistObject ){
+ $this->savePersistObject( $this->persistObject ); //save for next turn
+ }
+ if( $this->useLogObject ){ // add this turn's data to the log and save it for next turn
+ $this->saveLogObject( $this->logObject );
+ }
+ }
+
+ public function inviteAction( $reason ) {
+ $say = 'Hi, how can I help?';
+ // This is what you say upon your invitation to the conversation
+ return $say;
+ }
+
+ public function utteranceAction( $heard, $fromUri, $directedToMe, $directedToSomeoneElse ) {
+ $say = 'I heard: ' . $heard;
+ // This is a public message sent to everyone on the conversation
+ // Do your thing with $heard and create a $say
+ // Use the $directedToMe boolean to behave differently when not directed to you
+ return $say;
+ }
+
+ public function getManifestArray() {
+ $manifest = $this->agent['manifest'];
+ return $manifest;
+ }
+
+
+ //^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ // The above functions will be overloaded in your EXTENDED class
+ //^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+ public function __construct( $fileName ) {
+ $this->agentJSONFile = $fileName;
+ $this->agent = readJSONFromFile( $fileName );
+ $this->manifest = $this->agent['manifest'];
+ $this->URL = $this->agent['manifest']['identification']['serviceUrl'];
+ //$this->agentName = $this->agent['manifest']['identification']['conversationalName'];
+ if( isset($this->agent['manifest']['identification']['ALTserviceUrl']) ){
+ $this->AltURL = $this->agent['manifest']['identification']['ALTserviceUrl'];
+ }
+ $this->convoName = $this->agent['manifest']['identification']['conversationalName'];
+ $this->speakerUri = $this->agent['manifest']['identification']['speakerUri'];
+ }
+
+ public function shareOFPmsg( $oms ) {
+ $this->OFPTool = $oms;
+ }
+
+ public function useLog( $relativePath ){
+ // $relativePath [str e.g. '../../private']
+ $this->relativePathForFiles = $relativePath;
+ $this->useLogObject = true;
+ $cleanName = preg_replace('/[^a-zA-Z0-9_]/', '_', $this->convoName);
+ $cleanName .= '_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $this->convoId);
+ $this->logFileName = $relativePath . '/' . $cleanName .'.log';
+ }
+
+ public function getLogObject(){
+ // this will return null if not there so you must build one
+ $this->logObject = null;
+ if( $this->useLogObject ){
+ $this->logObject = readJSONFromFile( $this->logFileName );
+ }
+ return $this->logObject;
+ }
+
+ public function saveLogObject( $pObject ){
+ // this will save the object to a file for the next turn
+ if( $this->useLogObject && ($pObject != null) ){
+ $strJSON = json_encode( $pObject );
+ file_put_contents( $this->logFileName, $strJSON );
+ };
+ }
+
+ public function usePersist( $relativePath ){
+ // $relativePath [str e.g. '../../private']
+ $this->relativePathForFiles = $relativePath;
+ $this->usePersistObject = true;
+ $cleanName = preg_replace('/[^a-zA-Z0-9_]/', '_', $this->convoName);
+ $cleanName .= '_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $this->convoId);
+ $this->persistFileName = $relativePath . '/' . $cleanName .'.json';
+ }
+
+ public function getPersistObject(){
+ // this will return null if not there so you must build one
+ $this->persistObject = null;
+ if( $this->usePersistObject ){
+ $this->persistObject = readJSONFromFile( $this->persistFileName );
+ }
+ return $this->persistObject;
+ }
+
+ public function savePersistObject( $pObject ){
+ // this will save the object to a file for the next turn
+ if( $this->usePersistObject && ($pObject != null) ){
+ $strJSON = json_encode( $pObject );
+ file_put_contents( $this->persistFileName, $strJSON );
+ };
+ }
+
+ //public function getAgentName() {
+ // return $this->agentName;
+ //}
+
+ public function getURL() {
+ return $this->URL;
+ }
+
+ public function changeURL( $newURL) {
+ $this->URL = $newURL;
+ return $this->URL;
+ }
+
+ public function getAltURL(){
+ return $this->AltURL;
+ }
+
+ public function getSpeakerUri() {
+ return $this->speakerUri;
+ }
+
+ public function getConversationalName() {
+ return $this->convoName;
+ }
+
+ public function setConvoId( $someId ) {
+ $this->convoId = $someId;
+ }
+
+ public function addConvoPersistNote( $someNote ) {
+ // set time and note into this turn's data to be added to the persistant object at the end of the turn
+ $this->thisTurnData[] = [
+ 'time' => date('m-d-Y_H:i:s'),
+ 'note' => $someNote
+ ];
+ $this->logObject['convoLog'][] = $this->thisTurnData;
+ $this->saveLogObject( $this->logObject );
+ }
+}
+
+// Define the file path
+//$filePath = 'example.txt';
+$errors = [];
+
+function writeToFile($filePath, $content) {
+ // Open the file for writing; creates the file if it doesn't exist
+ $fileHandle = fopen($filePath, 'w');
+ if ($fileHandle) {
+ fwrite($fileHandle, $content); // Write content to file
+ fclose($fileHandle);
+ } else {
+ $errors[] = "Failed to open the file for writing.\n";
+ }
+}
+
+function readFromFile($filePath) {
+ if (file_exists($filePath)) { // file exists?
+ $fileHandle = fopen($filePath, 'r');
+ if ($fileHandle) {
+ $content = fread($fileHandle, filesize($filePath));
+ fclose($fileHandle);
+ return $content;
+ } else {
+ $errors[] = "Failed to open the file for reading.";
+ }
+ } else {
+ $errors[] = "File does not exist.";
+ }
+}
+
+function readJSONFromFile($filePath) {
+ //echo 'filePath = ' . $filePath . PHP_EOL; //ejcdbg
+ $data = null;
+ if (file_exists($filePath)) { // file exists?
+ $fileHandle = fopen($filePath, 'r');
+ if ($fileHandle) {
+ $content = fread($fileHandle, filesize($filePath));
+ fclose($fileHandle);
+ $data = json_decode($content, true); // Decode JSON to PHP variable
+ if (json_last_error() !== JSON_ERROR_NONE) { // Good decode?
+ $errors[] = "JSON Decode Error";
+ }
+ } else {
+ $errors[] = "Failed to open the file for reading." . PHP_EOL;
+ }
+ } else {
+ $errors[] = "File does not exist." . PHP_EOL;
+ }
+ return $data;
+}
+
+class FileIO {
+ private $path;
+
+ public function setPath($path) {
+ $this->path = $path;
+ }
+
+ public function ejReadFile($fileName, $type, $isArray = true) {
+ $filePath = $this->path . $fileName;
+ if (!file_exists($filePath)) {
+ throw new Exception("File not found: " . $filePath);
+ }
+ $fileContents = file_get_contents($filePath);
+ if ($type === 'json') {
+ return json_decode($fileContents, $isArray);
+ }
+ return $fileContents;
+ }
+}
+
+
+class SimpleNLP {
+ private $myConcepts = null;
+
+ public function __construct( $fileName ) {
+ $pData = file_get_contents( $fileName );
+ if( strlen($pData) > 40 ){
+ $this->myConcepts = json_decode($pData, true); // ready to use
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ echo 'json_decode error: ' . json_last_error_msg();
+ }
+ }else{ // not a good file so rebuild the LLM agent
+ echo 'no file for intents was found';
+ }
+
+ }
+
+ public function simpleIntentFromText($inputMessage) {
+ $matchedConcepts = [];
+ $message = ' ' . preg_replace('/[^\w\s]/', ' ', $inputMessage) . ' ';
+ $message = strtolower($message);
+ $words = '';
+
+ if ($this->myConcepts) {
+ foreach ($this->myConcepts as $concept) {
+ $matchedWords = array_filter($concept['examples'], function($word) use ($message) {
+ $spacedWord = ' ' . strtolower($word) . ' ';
+ return strpos($message, $spacedWord) !== false;
+ });
+
+ if (!empty($matchedWords)) {
+ foreach ($matchedWords as $value) { // only way to stop from getting the index also
+ $words = $value;
+ }
+ $matchedConcepts[] = [
+ "concept" => $concept['name'],
+ "matchedWords" => $words
+ ];
+ }
+ }
+ }
+ return $matchedConcepts;
+ }
+
+ public function simpleIntent($conceptJSON) {
+ $concept = "";
+ $intent = [
+ "return" => false,
+ "assistantName" => "",
+ "repeatLastUtt" => false,
+ "manifest" => false
+ ];
+
+ if ($conceptJSON) {
+ foreach ($conceptJSON as $conceptData) {
+ $concept = $conceptData['concept'];
+ if ($concept === "return") {
+ $intent["return"] = true;
+ } else if ($concept === "delegate") {
+ $intent["redirect"] = $concept;
+ } else if ($concept === "assistantName") {
+ $intent["assistantName"] = $conceptData['matchedWords'];
+ } else if ($concept === "repeatLastUtt") {
+ $intent["repeatLastUtt"] = true;
+ } else if ($concept === "manifest") {
+ $intent["manifest"] = true;
+ }
+ }
+ }
+ return $intent;
+ }
+
+ public function loadIntents( $type, $whatToSay ){
+ $myConcepts = [
+ [
+ "name" => "bye",
+ "examples" => [
+ "goodbye", "farewell", "bye", "see you", "have a good day", "goodnight", "talk to you later", "catch you later", "see you later", "take care", "I have to go", "I'm leaving", "until next time"
+ ]
+ ],
+ [
+ "name" => "maybe",
+ "examples" => [
+ "maybe", "perhaps", "not sure", "I don't know", "possibly", "could be", "might be", "it's a possibility", "I guess so", "I suppose so"
+ ]
+ ],
+ [
+ "name" => "yes",
+ "examples" => [
+ "yes", "yeah", "okay", "why not", "sure", "alright", "of course", "definitely"
+ ]
+ ],
+ [
+ "name" => "no",
+ "examples" => [
+ "no", "not now", "never", "don't want to", "nope", "not really", "don't think so"
+ ]
+ ],
+ [
+ "name" => "stillThere",
+ "examples" => [
+ "you still there", "you there", "are you listening", "can you here me"
+ ]
+ ],
+ [
+ "name" => "politeness",
+ "examples" => [
+ "thank you", "excuse me", "please", "sorry", "do you mind", "pardon me", "welcome", "excuse me", "my apologies", "no problem", "don't mention it", "that's alright"
+ ]
+ ],
+ [
+ "name" => "greeting",
+ "examples" => [
+ "hello", "hi", "hey", "how is it going", "good morning", "good afternoon", "good evening", "what's up", "howdy", "greetings", "salutations", "nice to meet you", "pleased to meet you"
+ ]
+ ]
+ ];
+ }
+}
+?>
\ No newline at end of file
diff --git a/V3/bfPHP/myAgFun.php b/V3/bfPHP/myAgFun.php
new file mode 100644
index 0000000..d1d46cd
--- /dev/null
+++ b/V3/bfPHP/myAgFun.php
@@ -0,0 +1,55 @@
+persistObject == null ){ // first time, so init it
+ $this->persistObject = [
+ 'exchanges' => []
+ ];
+ }
+ }
+
+ public function wrapUpAction() {
+ // Do whatever needs doing before leaving
+ // REMEMBER: persistObject will be stringified and saved
+ parent::wrapUpAction(); // You MUST call this to save the persist object
+ }
+
+ public function inviteAction( $reason ) {
+ $say = 'Thankyou for the invitation. got any crackers?';
+ // Note: if $reason=="@sentinal" then modify your behavior accordingly
+ return $say;
+ }
+
+ public function utteranceAction( $heard, $fromUri, $directedToMe, $directedToSomeoneElse ) {
+ // To be polite you may want to respond ONLY if $directedToMe is true.
+ // You may want to avoid responding if it is meant for someone else
+ if( strlen( $heard ) <1 ){
+ $heard = "nothing";
+ }
+ if( $directedToMe ){ // it was meant for me, so respond regardless.
+ $say = 'Thanks for referencing me! I heard you say ' . $heard . '. Polly wants a cracker!';
+ }else if( $directedToSomeoneElse ){// You may want to avoid responding here
+ $say = '';
+ }else{ // Must be directed to everyone
+ $say = 'I still need a cracker, but I heard '. $heard . ' and I am not sure if it was meant for me.';
+ }
+ // keep track of what you heard in the persistant object
+ $this->persistObject['exchanges'][] = ["utt" => $heard, "from" => $fromUri, "toMe" => $directedToMe, "toSomeoneElse" => $directedToSomeoneElse, "said" => $say];
+
+ return $say;
+ }
+}
+?>
\ No newline at end of file
diff --git a/V3/bfPHP/run.php b/V3/bfPHP/run.php
new file mode 100644
index 0000000..75c4c3d
--- /dev/null
+++ b/V3/bfPHP/run.php
@@ -0,0 +1,38 @@
+ json_last_error()]);
+ exit;
+}
+
+echo json_encode( simpleProcessOFP($inputOpenfloor, $agentDefinitionJSON ) ); // return OFP
+?>
\ No newline at end of file