Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions docs/PluginDevelopment.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,13 @@ created through `SoftwareProjectFactory`, which the creator dialog uses too.
- `DownloadAndExtractArchiveAsync(url, location, progress, timeout, cancellationToken)`.
- `DownloadImageAsync(url, timeout, cancellationToken)`, `DownloadTextAsync(url, timeout, cancellationToken)`.

#### `ISecretStore` (src/OneWare.Essentials/Services/ISecretStore.cs)

- `Get(service, account)`, `Set(service, account, secret)`, `Remove(service, account)`: secrets in the OS
credential store (Keychain, Secret Service, Windows: DPAPI-encrypted files in the app data folder).
- Calls may block (unlock prompts, D-Bus), so call them off the UI thread. Pick a `service` name unique to
your plugin. The OneWare Cloud login and the Copilot connectors use it.

#### `IChatService` (src/OneWare.Essentials/Services/IChatService.cs)

- `InitializeAsync()`: setup and authenticate.
Expand Down Expand Up @@ -547,6 +554,8 @@ created through `SoftwareProjectFactory`, which the creator dialog uses too.
- Set `OneWareAiFunction.InvocationHandler` when a tool needs an
`AiFunctionInvocationContext` for invocation-scoped progress reporting. Keep `Handler` as the
typed delegate used to generate the tool schema.
- Set `OneWareAiFunction.AlwaysLoaded` only on tools the model needs in almost every turn. When a chat has
many tools, the others are deferred behind the runtime's tool search and found by name and description.

#### `ISerialMonitorService` (src/OneWare.Essentials/Services/ISerialMonitorService.cs)

Expand Down Expand Up @@ -766,6 +775,10 @@ Key points:
wrote the message, and `ChatIdleEvent.Model` for turns whose messages carry no model. The chat
shows it beneath the message that ended the turn. `ChatSubAgentStartedEvent.Model` does the same
for a delegated task, shown in the header of its block.
- Show images a tool returned with `ChatImageEvent` (encoded bytes plus MIME type, optional description and
tool name). The chat shows them below the tool call, saves them under `<AppData>/Chat/Images/<sessionId>/`
and deletes them with their chat. The Copilot chats raise it for image contents and binary results of tool
calls, including images referenced through `session.binary_asset` events.
- Call `IChatManagerService.RegisterChatService` during module initialization.
- OneWare functions (tools from `IAiFunctionProvider`) report `AiFunctionEvent.SessionId` so the chat shows them in
the right conversation. Put an object with a public string `SessionId` property (like the Copilot SDK's `ToolInvocation`) in
Expand Down Expand Up @@ -1007,6 +1020,104 @@ Names must be unique; registering an existing name replaces the previous agent o
directories generated from `RegisterSkill` are cleaned up automatically when a skill is renamed or
no longer registered.

### User skills, MCP servers and repository instructions

The GitHub Copilot, BYOK and OneWare Agents chats reuse Copilot CLI customization discovery.
They read the user's Copilot configuration directory (`COPILOT_HOME`, or `~/.copilot` by default),
including user skills. MCP servers come from OneWare's own list (see Connectors below); the servers in
`~/.copilot/mcp-config.json` are not started in OneWare chats but can be imported. Provider-specific chat
history remains separate.

Add arbitrary skill roots in **Settings > AI Chat > Copilot CLI > Additional Skill Directories**.
These are merged with plugin skill directories, not substituted for them. Paths can be absolute,
start with `~/`, or be relative to the active project. Missing directories are logged and skipped.
Start or reopen a chat after changing customization settings.

New and resumed sessions use the active project's root as their working directory, falling back to
the default projects directory if no project is active. The runtime discovers project skills and
repository instructions, including `AGENTS.md` and `.github/copilot-instructions.md`, from that context.
The assistant is also instructed to read scoped `AGENTS.md` files when working in subdirectories,
because OneWare's `readFile` tool replaces the CLI's built-in file viewer.

#### Connectors (IDE-wide MCP servers)

**Settings > AI Chat > Connectors** lists the MCP servers available in every chat, independent of the open
projects, for GitHub Copilot and OneWare Agents alike (both run on the Copilot CLI runtime). The same list opens
from the chat's ⋯ menu (*Manage MCP Servers…*). Each server can be added,
edited, enabled or disabled, tested, signed in to and removed. *Import…* adds the servers of an existing file
(Copilot CLI `mcpServers`, VS Code `servers` or Claude Code layout), and the editor fills its form from a
pasted snippet, e.g. from a server's README.

- **Storage**: `McpConnectorHub` (`src/OneWare.Copilot/Connectors`) owns `<AppData>/Copilot/mcp.json` in the
Copilot CLI format (`mcpServers`), plus OneWare's fields `id`, `disabled`, `secretEnv` and `secretHeaders`.
The file can be edited by hand (*Open mcp.json*); changes are picked up while OneWare runs, entries without
an `id` get one, and a file with errors is reported without being overwritten.
- **Secrets**: environment variables and headers marked secret (the key toggle; credentials such as
`Authorization` or `*_TOKEN` are marked automatically) are left out of `mcp.json` and stored as one
`ISecretStore` entry per server (service `oneware-mcp`, account = the server's `id`). The OAuth client
secret of a confidential app is stored there as well. Other values may reference `${VAR}`; the runtime
expands them from its environment, and the editor warns about variables that are not set.
- **Environment**: OneWare starts the runtime with the login shell's environment (Linux/macOS, so tokens
exported in `~/.profile`, `~/.bashrc` or `~/.zshrc` and tools such as nvm's `npx` are found when OneWare is
started from a desktop launcher), overlaid with OneWare's own environment. `PATH` keeps the login shell's
entries first. Secret values are never put into that environment, because every local server inherits it.
- **Sessions**: every chat gets the enabled servers, with their secrets filled in, as session MCP servers
(`SessionConfig.McpServers`; SSE servers are started through `session.Rpc.Mcp.StartServerAsync`). The
runtime does not persist them. Servers of the Copilot CLI user configuration are disabled in OneWare chats
unless a connector has the same name, and the list offers to import them.
- **Live updates**: changing the list only touches the servers that changed in open chats (compared by a
fingerprint of their resolved configuration): new ones are started, changed ones restarted, removed or
disabled ones stopped.
- **Per chat**: the chat's ⋯ menu shows the servers of the current chat with their status, and turns servers
off or on for that chat only. Problems (failed, sign-in required) show as a warning dot on the ⋯ button
instead of being posted as chat messages.
- **Tool search**: chats enable the runtime's tool search (`SessionConfig.ToolSearch`), so servers with many
tools (Microsoft 365 has about 400) are looked up on demand instead of filling the model's context. Above
about 30 tools, MCP tools and plugin AI functions are deferred; the model finds them by name and
description, so give plugin functions precise, searchable descriptions. Functions flagged
`IOneWareAiFunction.AlwaysLoaded` (OneWare's file, terminal and IDE-context functions such as `readFile`,
`editFile` and `runTerminalCommand`) are never deferred.
- **Sign-in**: nothing is checked automatically when the list opens; *Test Connection* starts a server in a
separate helper session. *Sign In* runs the runtime's OAuth flow in the browser on the loopback redirect
`http://127.0.0.1:41347/` (in an open chat, through that chat's session); the runtime keeps the tokens in the
system keychain (`McpOAuthTokenStorage = Persistent`), and open chats reconnect. Only one sign-in runs at a
time. Servers without dynamic client registration (e.g. Asana) need an OAuth app registered with the
provider using that redirect URL: enter its client ID and, for confidential apps, its client secret under
*Advanced*.

A project's own `.mcp.json` (or `.vscode/mcp.json`) is only loaded by the Copilot runtime for the working
directory, and only if the Copilot CLI trusts that folder. When an untrusted project has one, the chat asks
once whether to trust the folder and start its servers. OneWare does not start MCP servers from other open
projects.

#### Multiple open projects

The runtime only knows one working directory, so OneWare adds the following from the other open projects
itself and keeps open chats in sync while projects are opened or closed:

- **Skills** in `.github/skills`, `.agents/skills` and `.claude/skills` of every open project are added.
- **Instructions**: the root `AGENTS.md` and `.github/copilot-instructions.md` of the other projects
are sent with the next message, together with the list of open projects. They are sent again only when the
open projects or those files change.

MCP tools use the normal permission flow, including per-chat approvals and the selected approval
mode. Ask/Plan and other read-only agents only allow MCP tools that the server marks read-only
(`readOnlyHint`). Host-capable built-in CLI tools remain unavailable, and customization discovery does not
enable repository command hooks.

#### Auto routing tiers and HydraFusion

The GitHub Copilot chat starts the runtime with the `DYNAMIC_AUTO_TIERS` feature flag
(`COPILOT_CLI_ENABLED_FEATURE_FLAGS`, merged with flags already set) and enables the HydraFusion session
feature flags (`CopilotSessionConfiguration.ApplyFusion`). The tiers of the **Auto** model picker come from
`client.Rpc.Models.ListAsync` (`ModelList.Auto`) and include Fusion tiers when the account offers them
(`CopilotAutoTiers.Build`); if the runtime offers none, the built-in Default, Fast, Efficiency, Balance and
Intelligence tiers are used. BYOK and OneWare Agents chats always use the built-in tiers and never enable Fusion.

During a Fusion turn, the root phases stay in the normal message flow. Review phases (judge, critic, …) are shown
as sub-agent blocks named *Fusion &lt;phase&gt;*, with their model, verdict, duration and token usage
(`CopilotChatService.Fusion.cs`).

### Skills for external agents

Studio chat skills and functions are not automatically available to external agents. To contribute
Expand Down
2 changes: 2 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
- Values of variables, registers and memory can be edited while the debugger is paused
- Extensions can now depend on other extensions, which are installed automatically, and removing an extension also offers to remove the extensions that need it
- Added a copy button and a "Copy Message" context menu entry to AI chat messages
- The Copilot auto model routing now offers the tiers available to your account, including HydraFusion, and shows the Fusion review steps in the chat
- Images returned by tools, such as screenshots from MCP servers, are now shown in the AI chat and can be opened or saved
- Enabled the Yosys flow for Gowin boards
- Fixed a crash when selecting text across a table in Markdown views
- Fixed a crash while highlighting text in the editor
Expand Down
26 changes: 8 additions & 18 deletions src/OneWare.Chat/Services/AiBuiltInFunctions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public static void Register(
functionProvider.RegisterFunction(new OneWareAiFunction
{
Name = "readFile",
AlwaysLoaded = true,
IsReadOnly = true,
FriendlyName = "Read File",
RunOnUiThread = true,
Expand All @@ -69,6 +70,7 @@ public static void Register(
functionProvider.RegisterFunction(new OneWareAiFunction
{
Name = "editFile",
AlwaysLoaded = true,
FriendlyName = "Edit File",
RunOnUiThread = true,
Description =
Expand Down Expand Up @@ -99,6 +101,7 @@ await EditFileAsync(projectExplorerService, aiFileEditService,
functionProvider.RegisterFunction(new OneWareAiFunction
{
Name = "getActiveProject",
AlwaysLoaded = true,
IsReadOnly = true,
FriendlyName = "Get Active Project",
RunOnUiThread = true,
Expand All @@ -114,6 +117,7 @@ await EditFileAsync(projectExplorerService, aiFileEditService,
functionProvider.RegisterFunction(new OneWareAiFunction
{
Name = "getOpenFiles",
AlwaysLoaded = true,
IsReadOnly = true,
FriendlyName = "Get Open Files",
RunOnUiThread = true,
Expand All @@ -132,27 +136,10 @@ Do not assume or invent open files.
DetailExtractor = _ => "open files"
});

functionProvider.RegisterFunction(new OneWareAiFunction
{
Name = "getFocusedFile",
IsReadOnly = true,
FriendlyName = "Get Focused File",
RunOnUiThread = true,
Description = """
Returns the absolute full path of the currently focused editor file.
This is the ONLY way to know which file is active.
""",
Handler = () => new
{
currentFile = ResolvePath(projectExplorerService, dockService.CurrentDocument?.FullPath)
},
DetailExtractor = _ => GetRelativePath(projectExplorerService, dockService.CurrentDocument?.FullPath)
?? "focused file"
});

functionProvider.RegisterFunction(new OneWareAiFunction
{
Name = "openFile",
AlwaysLoaded = true,
FriendlyName = "Open File",
RunOnUiThread = true,
Description = """
Expand All @@ -177,6 +164,7 @@ Optionally jumps to a specific line. Always pass an absolute path.
functionProvider.RegisterFunction(new OneWareAiFunction
{
Name = "getErrorsForFile",
AlwaysLoaded = true,
IsReadOnly = true,
FriendlyName = "Get Errors for File",
RunOnUiThread = true,
Expand All @@ -189,6 +177,7 @@ Optionally jumps to a specific line. Always pass an absolute path.
functionProvider.RegisterFunction(new OneWareAiFunction
{
Name = "getAllErrors",
AlwaysLoaded = true,
IsReadOnly = true,
FriendlyName = "Get Errors",
RunOnUiThread = true,
Expand All @@ -203,6 +192,7 @@ Optionally jumps to a specific line. Always pass an absolute path.
functionProvider.RegisterFunction(new OneWareAiFunction
{
Name = "runTerminalCommand",
AlwaysLoaded = true,
FriendlyName = "Execute In Terminal",
RunOnUiThread = true,
Description = """
Expand Down
11 changes: 11 additions & 0 deletions src/OneWare.Chat/Services/AiFunctionProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,17 @@ private static string ToYamlString(string value)
}
}

public bool IsFunctionAlwaysLoaded(string functionName)
{
EnsureBuiltInsRegistered();
lock (_registrationLock)
{
return _registeredFunctions
.FirstOrDefault(f => string.Equals(f.Name, functionName, StringComparison.Ordinal))
?.AlwaysLoaded == true;
}
}

public string? GetFriendlyName(string functionName)
{
EnsureBuiltInsRegistered();
Expand Down
65 changes: 65 additions & 0 deletions src/OneWare.Chat/Services/ChatImageStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Security.Cryptography;
using System.Text.RegularExpressions;

namespace OneWare.Chat.Services;

/// <summary>
/// Stores images that tools returned in a chat, one folder per session, so they survive restarts without bloating
/// the chat history files and are deleted together with their chat.
/// </summary>
internal sealed partial class ChatImageStore(string rootDirectory)
{
private const string UnassignedFolder = "_unassigned";

private static readonly Dictionary<string, string> Extensions = new(StringComparer.OrdinalIgnoreCase)
{
["image/png"] = ".png",
["image/jpeg"] = ".jpg",
["image/jpg"] = ".jpg",
["image/gif"] = ".gif",
["image/webp"] = ".webp",
["image/bmp"] = ".bmp",
["image/svg+xml"] = ".svg"
};

public string RootDirectory { get; } = rootDirectory;

public static string GetExtension(string? mimeType) =>
mimeType != null && Extensions.TryGetValue(mimeType.Split(';')[0].Trim(), out var extension)
? extension
: ".img";

/// <summary>Writes the image (once per content) and returns its path.</summary>
public string Store(string? sessionId, byte[] data, string? mimeType)
{
var directory = GetSessionDirectory(sessionId);
Directory.CreateDirectory(directory);

var hash = Convert.ToHexStringLower(SHA256.HashData(data))[..32];
var path = Path.Combine(directory, hash + GetExtension(mimeType));
if (!File.Exists(path))
{
var temporary = path + ".tmp";
File.WriteAllBytes(temporary, data);
File.Move(temporary, path, true);
}

return path;
}

public void DeleteSession(string? sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId)) return;

var directory = GetSessionDirectory(sessionId);
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}

public string GetSessionDirectory(string? sessionId) =>
Path.Combine(RootDirectory, string.IsNullOrWhiteSpace(sessionId)
? UnassignedFolder
: UnsafeCharacters().Replace(sessionId, "_"));

[GeneratedRegex("[^A-Za-z0-9_.-]|^\\.+$")]
private static partial Regex UnsafeCharacters();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using OneWare.Essentials.Controls;
using OneWare.Essentials.Helpers;
using OneWare.Essentials.Services;

namespace OneWare.Chat.ViewModels.ChatMessages;

/// <summary>An image a tool returned to the AI, stored next to the chat history.</summary>
public class ChatMessageImageViewModel : ObservableObject, IChatMessage, IEstimatedHeightItem
{
/// <summary>Maximum width the preview is decoded to, which keeps large screenshots cheap in memory.</summary>
private const int PreviewDecodeWidth = 1024;

private const double PreviewMaxHeight = 320;

private bool _loaded;

public ChatMessageImageViewModel(string filePath, string mimeType)
{
FilePath = filePath;
MimeType = mimeType;
}

public string FilePath { get; }

public string MimeType { get; }

public string? Description { get; init; }

public string? ToolName { get; init; }

public string Caption => !string.IsNullOrWhiteSpace(Description) ? Description
: !string.IsNullOrWhiteSpace(ToolName) ? $"Image from {ToolName}"
: "Image";

public string FileName => Path.GetFileName(FilePath);

/// <summary>The decoded preview, or null when the file is missing or cannot be decoded.</summary>
public Bitmap? Image
{
get
{
if (_loaded) return field;
_loaded = true;

try
{
if (!File.Exists(FilePath)) return null;

using var stream = File.OpenRead(FilePath);
var bitmap = new Bitmap(stream);
if (bitmap.PixelSize.Width > PreviewDecodeWidth)
{
var height = Math.Max(1, bitmap.PixelSize.Height * PreviewDecodeWidth / bitmap.PixelSize.Width);
var scaled = bitmap.CreateScaledBitmap(new Avalonia.PixelSize(PreviewDecodeWidth, height));
bitmap.Dispose();
bitmap = scaled;
}

field = bitmap;
}
catch (Exception e)
{
ContainerLocator.Container.Resolve<Microsoft.Extensions.Logging.ILogger>()
?.Warning($"Showing chat image '{FilePath}' failed", e);
field = null;
}

return field;
}
}

public bool HasImage => Image != null;

public void Open()
{
if (File.Exists(FilePath)) PlatformHelper.OpenHyperLink(FilePath);
}

public double EstimateHeight(double width) => PreviewMaxHeight + 24;
}
Loading
Loading