Repository: unternet-co/client
Pull request #110: Add ability to attach files to interaction inputs & translate to ai message
Author: login: icidasset
Created: 2025-04-04T17:00:08Z | Merged: 2025-04-09T04:38:48Z

Changes:
- Changes the interaction input type so you can attach multiple files to it.
- Adds `/file <paths>` command to the kernel example (works on folders too)
- Adds commands to intro text of kernel example
- Some minor changes to the kernel example input handler so that you can easily add multiple inputs in one interaction.


Diff:
diff --git a/kernel/example/package-lock.json b/kernel/example/package-lock.json
index d2424093..dd70e46d 100644
--- a/kernel/example/package-lock.json
+++ b/kernel/example/package-lock.json
@@ -10,7 +10,10 @@
       "dependencies": {
         "@unternet/kernel": "file:..",
         "chalk": "^5.4.1",
-        "readline": "^1.3.0"
+        "mime-types": "^3.0.1",
+        "readline": "^1.3.0",
+        "shell-quote": "^1.8.2",
+        "untildify": "^5.0.0"
       },
       "devDependencies": {
         "dotenv": "^16.4.7",
@@ -19,7 +22,7 @@
     },
     "..": {
       "name": "@unternet/kernel",
-      "version": "0.3.1",
+      "version": "0.3.2",
       "license": "ISC",
       "dependencies": {
         "@ai-sdk/openai": "^1.0.10",
@@ -558,6 +561,25 @@
         "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
       }
     },
+    "node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
+      "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
     "node_modules/readline": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/readline/-/readline-1.3.0.tgz",
@@ -574,6 +596,17 @@
         "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
       }
     },
+    "node_modules/shell-quote": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
+      "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/tsx": {
       "version": "4.19.3",
       "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz",
@@ -593,6 +626,14 @@
       "optionalDependencies": {
         "fsevents": "~2.3.3"
       }
+    },
+    "node_modules/untildify": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/untildify/-/untildify-5.0.0.tgz",
+      "integrity": "sha512-bOgQLUnd2G5rhzaTvh1VCI9Fo6bC5cLTpH17T5aFfamyXFYDbbdzN6IXdeoc3jBS7T9hNTmJtYUzJCJ2Xlc9gA==",
+      "engines": {
+        "node": ">=16"
+      }
     }
   }
 }
diff --git a/kernel/example/package.json b/kernel/example/package.json
index 780f4108..87c920b6 100644
--- a/kernel/example/package.json
+++ b/kernel/example/package.json
@@ -7,7 +7,10 @@
   "dependencies": {
     "@unternet/kernel": "file:..",
     "chalk": "^5.4.1",
-    "readline": "^1.3.0"
+    "mime-types": "^3.0.1",
+    "readline": "^1.3.0",
+    "shell-quote": "^1.8.2",
+    "untildify": "^5.0.0"
   },
   "devDependencies": {
     "dotenv": "^16.4.7",
diff --git a/kernel/example/src/commands/file.ts b/kernel/example/src/commands/file.ts
new file mode 100644
index 00000000..b9f24361
--- /dev/null
+++ b/kernel/example/src/commands/file.ts
@@ -0,0 +1,162 @@
+import * as nodeFs from 'node:fs';
+import * as nodePath from 'node:path';
+import chalk from 'chalk';
+import mime from 'mime-types';
+import parseShell from 'shell-quote/parse';
+import untildify from 'untildify';
+
+import { Interaction } from '../../../src';
+
+/**
+ * Create or extend an interaction based on user input.
+ */
+export function interaction(
+  userInput: string,
+  interaction?: Interaction
+): Interaction {
+  const rawParts =
+    userInput.replace(/^\/file /, '').match(/"[^"]+"|'[^']+'|\S+/g) ?? [];
+
+  const { files, textParts } = rawParts.reduce(
+    (
+      acc: {
+        files: Array<{ data: Uint8Array; filename: string; mimeType: string }>;
+        textParts: string[];
+      },
+      p: string
+    ) => {
+      const parsed_ = parseShell(p);
+      const parsed = parsed_[0];
+
+      if (
+        typeof parsed === 'string' &&
+        (parsed.startsWith('./') ||
+          parsed.startsWith('~/') ||
+          parsed.startsWith('/'))
+      ) {
+        const resolvedPath = untildify(parsed);
+        let files = acc.files;
+        let kind;
+
+        try {
+          const stats = nodeFs.lstatSync(resolvedPath);
+          kind = stats.isDirectory()
+            ? 'directory'
+            : stats.isFile()
+              ? 'file'
+              : undefined;
+        } catch (err) {
+          console.error(chalk.red(err));
+        }
+
+        switch (kind) {
+          case 'directory':
+            const dirFiles = readDir(resolvedPath, {
+              ignoreDotFiles: true,
+              recursive: true,
+            });
+
+            files = [...files, ...dirFiles];
+            break;
+
+          case 'file':
+            const filename = nodePath.basename(resolvedPath);
+            const mimeType = fileMimeType(filename);
+            const data = nodeFs.readFileSync(resolvedPath);
+
+            files = [
+              ...files,
+              { data: new Uint8Array(data), filename, mimeType },
+            ];
+            break;
+        }
+
+        return { ...acc, files };
+      }
+
+      return {
+        ...acc,
+        textParts: p.startsWith('--') ? acc.textParts : [...acc.textParts, p],
+      };
+    },
+    {
+      files: [],
+      textParts: [],
+    }
+  );
+
+  // Print files added to stdout
+  if (files.length) console.log('');
+
+  files.forEach((file) => {
+    console.log(chalk.italic(`File added: '${file.filename}'`));
+  });
+
+  // Fin
+  return {
+    input: {
+      text: textParts.length ? textParts.join(' ') : interaction?.input?.text,
+      files: [...(interaction?.input?.files || []), ...files],
+    },
+    outputs: interaction?.outputs || [],
+  };
+}
+
+/* 🛠️ */
+
+function readDir(
+  path: string,
+  opts: {
+    ignoreDotFiles: boolean;
+    recursive: boolean;
+  }
+) {
+  const dirFiles = nodeFs.readdirSync(path, {
+    withFileTypes: true,
+    recursive: true,
+  });
+
+  return dirFiles.reduce(
+    (
+      acc: Array<{
+        data: Uint8Array;
+        filename: string;
+        mimeType: string;
+      }>,
+      f
+    ) => {
+      if (opts.ignoreDotFiles && f.name.startsWith('.')) return acc;
+
+      const mimeType = fileMimeType(f.name);
+      const childPath = nodePath.join(f.parentPath, f.name);
+
+      if (f.isDirectory() && opts.recursive) {
+        if (['.cargo', '.git', 'node_modules'].includes(f.name)) return acc;
+
+        const nested = readDir(childPath, opts);
+        return [...acc, ...nested];
+      }
+
+      let data;
+
+      try {
+        if (f.isFile()) data = nodeFs.readFileSync(childPath);
+      } catch (error) {
+        console.error(chalk.red(error));
+      }
+
+      if (!data) return acc;
+
+      return [
+        ...acc,
+        { data: new Uint8Array(data), filename: f.name, mimeType },
+      ];
+    },
+    []
+  );
+}
+
+export function fileMimeType(name: string) {
+  if (name.endsWith('.ts')) return 'text/plain';
+  return mime.lookup(name) || undefined;
+}
diff --git a/kernel/example/src/commands/index.ts b/kernel/example/src/commands/index.ts
new file mode 100644
index 00000000..2485c864
--- /dev/null
+++ b/kernel/example/src/commands/index.ts
@@ -0,0 +1 @@
+export * as file from './file';
diff --git a/kernel/example/src/index.ts b/kernel/example/src/index.ts
index 7e3a0889..01362d80 100644
--- a/kernel/example/src/index.ts
+++ b/kernel/example/src/index.ts
@@ -1,70 +1,162 @@
 import { openai } from '@ai-sdk/openai';
 import readline from 'readline';
-import resources from './resources';
 import chalk from 'chalk';
 import 'dotenv/config';
-import { ActionOutput, Interaction, Interpreter } from '../../src';
-import { Dispatcher } from '../../dist';
+
+import {
+  ActionOutput,
+  Interaction,
+  Interpreter,
+  Dispatcher,
+  TextResponse,
+  ActionResponse,
+} from '../../src';
+import { Command } from './types';
 import { protocols } from './protocols';
-import { createInteraction } from '../../src/utils';
+import * as commands from './commands';
+import resources from './resources';
+
+/* MODEL & KERNEL SETUP */
 
 const model = openai('gpt-4-turbo');
 const interpreter = new Interpreter({ model, resources });
 const dispatcher = new Dispatcher(protocols);
-const interactions: Array<Interaction> = [];
+
+/* CLI INPUT & OUTPUT MANAGEMENT */
+
+const interactions: Interaction[] = [];
 const rl = readline.createInterface({
   input: process.stdin,
   output: process.stdout,
 });
 
-async function handleInput(userInput: string) {
-  if (userInput.toLowerCase() === 'exit') {
-    console.log(chalk.bold('\nGoodbye!'));
-    rl.close();
-    return;
-  }
+/**
+ * CLI input handler.
+ *
+ * Supports interaction reuse, hence the function currying.
+ */
+const handleInput =
+  (interaction?: Interaction) => async (userInput: string) => {
+    // Execute a command if one was provided,
+    // and ensure an interaction.
+    switch (command(userInput)) {
+      case 'exit':
+        console.log(chalk.bold('\nGoodbye!'));
+        rl.close();
+        return;
 
-  const interaction: Interaction = createInteraction(userInput);
-  interactions.push(interaction);
+      case 'file':
+        interaction = commands.file.interaction(userInput, interaction);
+        break;
 
-  try {
-    console.log(chalk.bold(`\nKernel`));
-    const response = await interpreter.generateResponse(interactions);
+      default:
+        interaction = interaction || { input: {}, outputs: [] };
+        interaction.input.text = userInput;
+    }
+
+    // If no text was assigned to the interaction input,
+    // stop here and initiate another user prompt.
+    if (!interaction.input.text) return promptUser(interaction);
+
+    // Add interaction to stack
+    interactions.push(interaction);
 
-    if (response.type === 'text') {
-      let totalText = '';
-      for await (const part of response.textStream) {
-        totalText += part;
-        process.stdout.write(part);
+    // Interpret the interaction
+    try {
+      console.log(chalk.bold(`\nKernel`));
+      const response = await interpreter.generateResponse(interactions);
+
+      switch (response?.type) {
+        case 'text':
+          await textResponse(response, interaction);
+          break;
+        case 'action':
+          await actionResponse(response, interaction);
+          break;
+        default:
+          console.log(
+            chalk.red(
+              'Sorry, failed to construct a response for this interaction.'
+            )
+          );
       }
-      process.stdout.write('\n');
-      interaction.outputs = [
-        {
-          type: 'text',
-          content: totalText,
-        },
-      ];
-    } else {
-      const output: ActionOutput = {
-        type: 'action',
-        directive: response.directive,
-        content: {},
-      };
-
-      output.content = await dispatcher.dispatch(response.directive);
-      console.log(output);
-      interaction.outputs = [output];
+    } catch (error) {
+      console.error(chalk.red('Error:', error));
+      console.error(error);
     }
-  } catch (error) {
-    console.error(chalk.red('Error:', error));
+
+    // Initiate another user prompt
+    promptUser();
+  };
+
+/**
+ * Check if a command was used or not.
+ */
+function command(userInput: string): Command | null {
+  const lowerInput = userInput.toLowerCase();
+
+  if (lowerInput === 'exit' || lowerInput === '/exit') {
+    return 'exit';
+  }
+
+  if (lowerInput.startsWith('/file ')) {
+    return 'file';
   }
 
-  promptUser();
+  return null;
 }
 
-function promptUser() {
-  rl.question(chalk.bold('\nYou\n'), handleInput);
+/**
+ * Manage an action response from the interpreter.
+ */
+async function actionResponse(
+  response: ActionResponse,
+  interaction: Interaction
+) {
+  const output: ActionOutput = {
+    type: 'action',
+    directive: response.directive,
+    content: {},
+  };
+
+  output.content = await dispatcher.dispatch(response.directive);
+  console.log(output);
+  interaction.outputs = [output];
+}
+
+/**
+ * Manage a text response from the interpreter.
+ */
+async function textResponse(response: TextResponse, interaction: Interaction) {
+  let totalText = '';
+  for await (const part of response.textStream) {
+    totalText += part;
+    process.stdout.write(part);
+  }
+  process.stdout.write('\n');
+  interaction.outputs = [
+    {
+      type: 'text',
+      content: totalText,
+    },
+  ];
 }
 
+/* PROMPTS */
+
+function promptUser(interaction?: Interaction) {
+  rl.question(chalk.bold('\nYou\n'), handleInput(interaction));
+}
+
+/* PRINT INTRO */
+
 console.log(chalk.italic('Chat with the kernel! Type "exit" to quit.'));
+console.log('\nAdditional commands:');
+console.log(
+  '/file',
+  '<path(s)_to_file_or_folder> <optional_msg_for_assistant>'
+);
+
+/* 🚀 INITIAL PROMPT */
+
 promptUser();
diff --git a/kernel/example/src/types.ts b/kernel/example/src/types.ts
new file mode 100644
index 00000000..96d8d6ad
--- /dev/null
+++ b/kernel/example/src/types.ts
@@ -0,0 +1 @@
+export type Command = 'exit' | 'file';
diff --git a/kernel/package.json b/kernel/package.json
index 2677e182..1d026dd9 100644
--- a/kernel/package.json
+++ b/kernel/package.json
@@ -12,7 +12,7 @@
   "scripts": {
     "build": "tsc",
     "dev": "chokidar 'src/**/*' -c 'npm run build'",
-    "run:example": "cd example && tsx src/index.ts"
+    "run:example": "cd example && npx tsx src/index.ts"
   },
   "dependencies": {
     "@ai-sdk/openai": "^1.0.10",
diff --git a/kernel/src/interpreter.ts b/kernel/src/interpreter.ts
index 49322a18..306559b5 100644
--- a/kernel/src/interpreter.ts
+++ b/kernel/src/interpreter.ts
@@ -39,14 +39,16 @@ export class Interpreter {
    */
   async generateResponse(
     interactions: Array<Interaction>
-  ): Promise<InterpreterResponse> {
+  ): Promise<InterpreterResponse | null> {
     const textAction = { description: this.prompts.textActionDescription };
     const directive = await this.createDirective(interactions, {
       ...{ text: textAction },
       ...this.actions,
     });
 
-    if (directive.protocol === 'text') {
+    if (!directive) {
+      return null;
+    } else if (directive.protocol === 'text') {
       return this.createTextResponse(interactions);
     } else {
       return {
@@ -70,7 +72,7 @@ export class Interpreter {
   private async createDirective(
     interactions: Array<Interaction>,
     actions: Record<string, ActionDefinition>
-  ): Promise<ActionDirective> {
+  ): Promise<ActionDirective | null> {
     const output = await generateObject({
       model: this.model,
       messages: createMessages(
@@ -81,7 +83,7 @@ export class Interpreter {
     });
 
     const { functions } = output.object as ActionChoiceObject;
-    if (!functions || !functions[0].id) return null;
+    if (!functions || !functions[0]?.id) return null;
 
     const fn = functions[0];
     const { protocol, resourceId, actionId } = decodeActionUri(fn.id);
diff --git a/kernel/src/types.ts b/kernel/src/types.ts
index 575d0606..68444698 100644
--- a/kernel/src/types.ts
+++ b/kernel/src/types.ts
@@ -6,6 +6,8 @@ import type {
   LanguageModel,
 } from 'ai';
 
+/* Messages */
+
 export type Message =
   | CoreSystemMessage
   | CoreUserMessage
@@ -17,8 +19,17 @@ export interface Interaction {
   outputs: InteractionOutput[];
 }
 
+/* Interactions */
+
 export interface InteractionInput {
-  text: string;
+  text?: string;
+  files?: FileInput[];
+}
+
+export interface FileInput {
+  data: Uint8Array;
+  filename?: string;
+  mimeType?: string;
 }
 
 export interface TextOutput {
@@ -34,14 +45,20 @@ export interface ActionOutput {
 
 export type InteractionOutput = TextOutput | ActionOutput;
 
+/* Interpreter */
+
 export type InterpreterResponse = TextResponse | ActionResponse;
 
+/* Interpreter – Text */
+
 export interface TextResponse {
   type: 'text';
   text: Promise<string>;
   textStream: AsyncIterable<string>;
 }
 
+/* Interpreter – Actions */
+
 export interface ActionResponse {
   type: 'action';
   directive: ActionDirective;
@@ -78,6 +95,8 @@ export interface Resource {
   actions?: Record<string, ActionDefinition>;
 }
 
+/* Protocol */
+
 export type ProtocolHandler = (
   directive: ActionDirective
 ) => Promise<any> | any;
@@ -87,7 +106,7 @@ export interface Protocol {
   handler: ProtocolHandler;
 }
 
-export type StringEnum = [string, ...string[]];
+/* JSON Schema */
 
 export interface JSONSchemaDefinition {
   type:
@@ -105,3 +124,7 @@ export interface JSONSchemaDefinition {
   required?: string[];
   additionalProperties?: boolean;
 }
+
+/* Misc */
+
+export type StringEnum = [string, ...string[]];
diff --git a/kernel/src/utils.ts b/kernel/src/utils.ts
index 4f6a9715..df194a91 100644
--- a/kernel/src/utils.ts
+++ b/kernel/src/utils.ts
@@ -1,6 +1,8 @@
+import { FilePart, ImagePart, TextPart } from 'ai';
 import {
   ActionOutput,
   ActionRecord,
+  FileInput,
   Interaction,
   InteractionInput,
   InteractionOutput,
@@ -60,10 +62,21 @@ export function createMessages(
   let messages: Message[] = [];
 
   for (let interaction of interactions) {
-    messages.push({
-      role: 'user',
-      content: interaction.input.text,
-    });
+    if (interaction.input.text)
+      messages.push({
+        role: 'user',
+        content: interaction.input.text,
+      });
+
+    if (interaction.input.files?.length) {
+      const parts: Array<TextPart | ImagePart | FilePart> =
+        interaction.input.files.map(fileMessage);
+
+      messages.push({
+        role: 'user',
+        content: parts,
+      });
+    }
 
     if (!interaction.outputs) continue;
 
@@ -128,3 +141,25 @@ export function decodeActionUri(encodedActionURI: string): UriComponents {
     actionId,
   };
 }
+
+export function fileMessage(file: FileInput): TextPart | ImagePart | FilePart {
+  if (file.mimeType.startsWith('text/') || file.mimeType === 'application/json')
+    return {
+      type: 'text',
+      text: new TextDecoder().decode(file.data),
+    };
+
+  if (file.mimeType.startsWith('image/'))
+    return {
+      type: 'image',
+      image: file.data,
+      mimeType: file.mimeType,
+    };
+
+  return {
+    type: 'file',
+    data: file.data,
+    filename: file.filename,
+    mimeType: file.mimeType,
+  };
+}


Reviews:
user: login: icidasset
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T17:01:20Z
user: login: icidasset
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T17:02:29Z
user: login: rupertsworld
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T03:46:30Z
user: login: rupertsworld
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T04:03:43Z
user: login: icidasset
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T11:44:26Z
user: login: icidasset
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T11:44:41Z
user: login: icidasset
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T13:27:10Z
user: login: icidasset
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T13:32:54Z
user: login: rupertsworld
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T04:37:22Z
user: login: rupertsworld
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T04:37:35Z

Review comments:
diff_hunk: @@ -17,8 +19,17 @@ export interface Interaction {
   outputs: InteractionOutput[];
 }
 
+/* Interactions */
+
 export interface InteractionInput {
-  text: string;
+  text?: string;
path: kernel/src/types.ts
user: login: icidasset
body: Made text optional, needs more testing. I assume you need to be able to just send a file to the model without adding any additional context.
created_at: 2025-04-04T17:01:20Z
author_association: MEMBER
diff_hunk: @@ -12,7 +12,7 @@
   "scripts": {
     "build": "tsc",
     "dev": "chokidar 'src/**/*' -c 'npm run build'",
-    "run:example": "cd example && tsx src/index.ts"
+    "run:example": "cd example && npx tsx src/index.ts"
path: kernel/package.json
user: login: icidasset
body: Didn't work for me without the `npx` here for some reason.
created_at: 2025-04-04T17:02:28Z
author_association: MEMBER
diff_hunk: @@ -17,54 +19,87 @@ const rl = readline.createInterface({
   output: process.stdout,
 });
 
-async function handleInput(userInput: string) {
-  if (userInput.toLowerCase() === 'exit') {
-    console.log(chalk.bold('\nGoodbye!'));
-    rl.close();
-    return;
-  }
+/* Input handler */
 
-  const interaction: Interaction = createInteraction(userInput);
-  interactions.push(interaction);
+const handleInput =
+  (interaction?: Interaction) => async (userInput: string) => {
+    const lowerInput = userInput.toLowerCase();
 
-  try {
-    console.log(chalk.bold(`\nKernel`));
-    const response = await interpreter.generateResponse(interactions);
+    if (lowerInput === 'exit' || lowerInput === '/exit') {
+      console.log(chalk.bold('\nGoodbye!'));
+      rl.close();
+      return;
+    }
 
-    if (response.type === 'text') {
-      let totalText = '';
-      for await (const part of response.textStream) {
-        totalText += part;
-        process.stdout.write(part);
-      }
-      process.stdout.write('\n');
-      interaction.outputs = [
+    if (lowerInput.startsWith('/file ') || lowerInput.startsWith('/files ')) {
path: kernel/example/src/index.ts
user: login: rupertsworld
body: Would love a slight tweak to simplify this:

- Just have '/file' (no '/folder' or '/files')
- Recursive on folders by default (no flags)
- Let's make it so that if you hit a newline after entering /file [filename] the file is added and you can then make a subsequent query (so I can do a demo where I add multiple files)

e.g. (where * represents italics)

```
You:
/file my-file.png

*File added: 'my-file.png'*

You:
what is this a photo of?
```
created_at: 2025-04-08T03:46:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,54 +19,87 @@ const rl = readline.createInterface({
   output: process.stdout,
 });
 
-async function handleInput(userInput: string) {
-  if (userInput.toLowerCase() === 'exit') {
-    console.log(chalk.bold('\nGoodbye!'));
-    rl.close();
-    return;
-  }
+/* Input handler */
 
-  const interaction: Interaction = createInteraction(userInput);
-  interactions.push(interaction);
+const handleInput =
+  (interaction?: Interaction) => async (userInput: string) => {
+    const lowerInput = userInput.toLowerCase();
 
-  try {
-    console.log(chalk.bold(`\nKernel`));
-    const response = await interpreter.generateResponse(interactions);
+    if (lowerInput === 'exit' || lowerInput === '/exit') {
+      console.log(chalk.bold('\nGoodbye!'));
+      rl.close();
+      return;
+    }
 
path: kernel/example/src/index.ts
user: login: rupertsworld
body: Can we make this parsing a but more modular to ensure this index file is super clear & readable for new folks?
created_at: 2025-04-08T04:03:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,54 +19,87 @@ const rl = readline.createInterface({
   output: process.stdout,
 });
 
-async function handleInput(userInput: string) {
-  if (userInput.toLowerCase() === 'exit') {
-    console.log(chalk.bold('\nGoodbye!'));
-    rl.close();
-    return;
-  }
+/* Input handler */
 
-  const interaction: Interaction = createInteraction(userInput);
-  interactions.push(interaction);
+const handleInput =
+  (interaction?: Interaction) => async (userInput: string) => {
+    const lowerInput = userInput.toLowerCase();
 
-  try {
-    console.log(chalk.bold(`\nKernel`));
-    const response = await interpreter.generateResponse(interactions);
+    if (lowerInput === 'exit' || lowerInput === '/exit') {
+      console.log(chalk.bold('\nGoodbye!'));
+      rl.close();
+      return;
+    }
 
-    if (response.type === 'text') {
-      let totalText = '';
-      for await (const part of response.textStream) {
-        totalText += part;
-        process.stdout.write(part);
-      }
-      process.stdout.write('\n');
-      interaction.outputs = [
+    if (lowerInput.startsWith('/file ') || lowerInput.startsWith('/files ')) {
path: kernel/example/src/index.ts
user: login: icidasset
body: > Let's make it so that if you hit a newline after entering /file [filename] the file is added and you can then make a subsequent query

You can actually do this already (allows for both newline and non-newline).
I'll add the message though 👍 

> Just have '/file'

Sounds good!

> Recursive on folders by default (no flags)

Can do. Just to be clear, this does read all the contents of every file in the directory, so this can blow up accidentally very easily. The ai sdk doesn't have the concept of folders, or even files without data. Or... I guess we could construct some text that just names all the files? Thoughts?
created_at: 2025-04-08T11:44:26Z
author_association: MEMBER
diff_hunk: @@ -17,54 +19,87 @@ const rl = readline.createInterface({
   output: process.stdout,
 });
 
-async function handleInput(userInput: string) {
-  if (userInput.toLowerCase() === 'exit') {
-    console.log(chalk.bold('\nGoodbye!'));
-    rl.close();
-    return;
-  }
+/* Input handler */
 
-  const interaction: Interaction = createInteraction(userInput);
-  interactions.push(interaction);
+const handleInput =
+  (interaction?: Interaction) => async (userInput: string) => {
+    const lowerInput = userInput.toLowerCase();
 
-  try {
-    console.log(chalk.bold(`\nKernel`));
-    const response = await interpreter.generateResponse(interactions);
+    if (lowerInput === 'exit' || lowerInput === '/exit') {
+      console.log(chalk.bold('\nGoodbye!'));
+      rl.close();
+      return;
+    }
 
path: kernel/example/src/index.ts
user: login: icidasset
body: Definitely!
created_at: 2025-04-08T11:44:41Z
author_association: MEMBER
diff_hunk: @@ -70,7 +72,7 @@ export class Interpreter {
   private async createDirective(
     interactions: Array<Interaction>,
     actions: Record<string, ActionDefinition>
-  ): Promise<ActionDirective> {
+  ): Promise<ActionDirective | null> {
path: kernel/src/interpreter.ts
user: login: icidasset
body: @rupertsworld An additional change here, this function had an incorrect return type signature. I bubbled this `null` up to the top if that's ok?
created_at: 2025-04-08T13:27:10Z
author_association: MEMBER
diff_hunk: @@ -17,54 +19,87 @@ const rl = readline.createInterface({
   output: process.stdout,
 });
 
-async function handleInput(userInput: string) {
-  if (userInput.toLowerCase() === 'exit') {
-    console.log(chalk.bold('\nGoodbye!'));
-    rl.close();
-    return;
-  }
+/* Input handler */
 
-  const interaction: Interaction = createInteraction(userInput);
-  interactions.push(interaction);
+const handleInput =
+  (interaction?: Interaction) => async (userInput: string) => {
+    const lowerInput = userInput.toLowerCase();
 
-  try {
-    console.log(chalk.bold(`\nKernel`));
-    const response = await interpreter.generateResponse(interactions);
+    if (lowerInput === 'exit' || lowerInput === '/exit') {
+      console.log(chalk.bold('\nGoodbye!'));
+      rl.close();
+      return;
+    }
 
path: kernel/example/src/index.ts
user: login: icidasset
body: I've refactor the index file and reorganised the file stuff into a commands directory, let me know what you think. I could further clean up `commands/file.ts` if you want, the `interaction` function is quite big and not readable.
created_at: 2025-04-08T13:32:54Z
author_association: MEMBER
diff_hunk: @@ -70,7 +72,7 @@ export class Interpreter {
   private async createDirective(
     interactions: Array<Interaction>,
     actions: Record<string, ActionDefinition>
-  ): Promise<ActionDirective> {
+  ): Promise<ActionDirective | null> {
path: kernel/src/interpreter.ts
user: login: rupertsworld
body: Yep great!
created_at: 2025-04-09T04:37:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -12,7 +12,7 @@
   "scripts": {
     "build": "tsc",
     "dev": "chokidar 'src/**/*' -c 'npm run build'",
-    "run:example": "cd example && tsx src/index.ts"
+    "run:example": "cd example && npx tsx src/index.ts"
path: kernel/package.json
user: login: rupertsworld
body: Not sure exactly why this is, but fine to leave for now
created_at: 2025-04-09T04:37:34Z
author_association: CONTRIBUTOR

PR comments:
user: login: rupertsworld
created_at: 2025-04-08T04:03:54Z
body: Hey @icidasset this all looks awesome – just added a couple of comments, once resolved will merge
author_association: CONTRIBUTOR
user: login: icidasset
created_at: 2025-04-08T13:41:55Z
body: Feedback applied ✅
author_association: MEMBER
user: login: rupertsworld
created_at: 2025-04-09T04:38:41Z
body: Beautifully done! Accepted.
author_association: CONTRIBUTOR