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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.PHONY: build run test fmt vet package package-host clean

BIN := bin/kandev-plugin-notes
VERSION := 0.1.0
VERSION := 0.2.0
STAGE := .build/stage
PKG_OUT := kandev-plugin-notes-$(VERSION).tar.gz

Expand Down
68 changes: 59 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,49 @@
# kandev-plugin-notes

A [Kandev](https://github.com/kdlbs/kandev) plugin that gives every task a
private, rich-text notes scratchpad.
private markdown notes scratchpad, with a formatting toolbar and optional
AI-assisted proofreading.

## What it does

- **Notes task panel** — a dockview panel in the task workspace (`+` → Notes),
also available on a phone under the grouped **Panels** bottom-nav action.
Rich-text editing (headings, lists, code blocks) via the host's own tiptap
editor, with debounced autosave.
- **Markdown editing with a formatting toolbar** — a scrollable markdown
`<textarea>` (bold, italic, headings, bullet/numbered lists, links, inline
code, and code blocks), shared byte-for-byte by the task panel and the
kanban modal, with debounced autosave. The toolbar's actions insert or wrap
the right markdown around your current selection (or at the caret) so you
never have to remember the syntax.
- **Enhance with AI** — a button next to the toolbar sends the note's current
markdown to your configured utility agent to proofread grammar, spelling,
and clarity. The result is shown as a preview with **Accept**/**Discard**
before it ever replaces your note — nothing is overwritten automatically.
See "Notes are private to you" below for the privacy trade-off this makes.
- **Kanban shortcut** — `Edit > Edit notes` on a kanban card opens the same
editor in a modal, so you can jot something down without opening the task.
editor (toolbar, AI button included) in a fixed-size modal — the modal
itself never grows as you type; the editor scrolls internally instead.
- **Card indicator** — a small glyph on cards that have a note.
- **Cross-tab sync** — an edit in one tab shows up in another without a reload.

## Notes are private to you
## Notes are private to you — except when you ask AI to enhance one

Each note is stored per **user**, per **task**, under the plugin's own key
(`("task", <taskId>, "note")`) via Kandev's per-user plugin storage
(`capabilities.user_state`). Two people looking at the same task each see their
own note; nobody else can read yours, and the agent cannot read or write it.
own note; nobody else can read yours, and the agent working the task cannot
read or write it.

If you want the agent to see something, put it in the task description or say it
in chat. This is a scratchpad, not a shared field.
**The one exception is the "Enhance with AI" button.** Clicking it sends the
note's current markdown to the utility agent configured for this plugin
(**Settings > Plugins > Notes**) via a one-shot completion
(`capabilities.agent_invoke` / `Host.InvokeUtilityAgent`) — that content
leaves the "nobody else can read it" boundary for that one request. If no
utility agent is configured, the button shows a clear, non-fatal message
instead of failing silently. Skip the button entirely to keep a note fully
private.

If you want the task's own agent to see something, put it in the task
description or say it in chat. This is a scratchpad, not a shared field.

## Install

Expand All @@ -37,7 +58,10 @@ curl -F "package=@kandev-plugin-notes-<version>.tar.gz" \

Sideloaded plugins register disabled/unverified; enable it in
**Settings > Plugins**. Reinstalling the same version returns 409 — bump the
version in `manifest.yaml` (and `Makefile`) first.
version in `manifest.yaml` (and `Makefile`) first. To use "Enhance with AI",
also pick a utility agent for this plugin under **Settings > Plugins > Notes**
(`config_schema.utility_agent`) — without one, the button surfaces a
not-configured message rather than failing.

## Development

Expand Down Expand Up @@ -65,3 +89,29 @@ and the [manifest reference](https://github.com/kdlbs/kandev/blob/main/docs/publ
## License

MIT — see [LICENSE](LICENSE).


The Kandev plugin SDK (`pkg/pluginsdk`) is not yet published as a standalone Go
module, so `go.mod` uses a local `replace` that expects the Kandev monorepo as a
**sibling checkout**:

```text
some-dir/
├── kandev/ # https://github.com/kdlbs/kandev — Go module at apps/backend/
└── kandev-plugin-notes/ # this repo
```

```sh
make build # host binary
make test # Go + JS unit tests
make vet
make package-host # host-platform package — fastest local loop
make package # all five platforms
```

Start with the [plugin authoring guide](https://github.com/kdlbs/kandev/blob/main/docs/public/plugins-authoring.md)
and the [manifest reference](https://github.com/kdlbs/kandev/blob/main/docs/public/plugins-manifest.md).

## License

MIT — see [LICENSE](LICENSE).
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.26.0
require (
github.com/kandev/kandev v0.0.0-00010101000000-000000000000
github.com/stretchr/testify v1.11.1
google.golang.org/grpc v1.82.1
gopkg.in/yaml.v3 v3.0.1
)

Expand All @@ -23,7 +24,6 @@ require (
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

Expand Down
30 changes: 26 additions & 4 deletions manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
# (/api/plugins/<id>/...). Keep the three in sync when bumping version.
id: "kandev-plugin-notes"
api_version: 1
version: "0.1.0"
version: "0.2.0"
display_name: "Notes"
description: "A private, per-user scratchpad note on any task: a dockview/mobile panel, rich-text editing, and a kanban card shortcut."
description: "A private, per-user scratchpad note on any task: a dockview/mobile panel, markdown editing with a formatting toolbar, AI-assisted proofreading, and a kanban card shortcut."
author: "yattdev"
categories: ["tools"]
repo_url: "https://github.com/yattdev/kandev-plugin-notes"
Expand All @@ -26,10 +26,32 @@ runtime:
windows-amd64: "server/plugin-windows-amd64.exe"

# capabilities gate what the host will do for this plugin. This plugin needs
# only per-user browser-reachable storage (host.storage) for the note itself
# — no events, no Host state, no webhooks, no API read/write.
# per-user browser-reachable storage (host.storage) for the note itself, plus
# agent_invoke so the "Enhance with AI" button can run a one-shot proofreading
# completion via Host.InvokeUtilityAgent (see server/plugin.go and the
# `utility_agent` config property below).
capabilities:
user_state: true
agent_invoke: true

# webhooks: the "Enhance with AI" button POSTs the note's current markdown to
# this webhook (relayed at POST /api/plugins/kandev-plugin-notes/webhooks/enhance);
# server/plugin.go's HandleWebhook proofreads it via Host.InvokeUtilityAgent
# and returns the improved markdown for the UI's preview/Accept/Discard flow.
webhooks:
- key: "enhance"
description: "Proofread and improve the current note's markdown via the configured utility agent"
method: "POST"

# config_schema: agent_invoke requires a utility_agent picker (rendered at
# Settings > Plugins > Notes) so the operator selects which agent runs the
# enhance completion. Calling Host.InvokeUtilityAgent without a valid
# selection here returns a distinguishable FailedPrecondition (see
# server/plugin.go), surfaced by the UI as a non-fatal message.
config_schema:
type: object
properties:
utility_agent: { type: string, format: utility-agent, title: "Utility Agent", description: "Agent used to proofread notes for the Enhance with AI button" }

# ui.bundle points at the hand-written, no-build ES module frontend half.
ui:
Expand Down
21 changes: 16 additions & 5 deletions server/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,26 @@ func TestManifestVersion_MatchesMakefileVersion(t *testing.T) {
require.Equal(t, m.Version, makefileVersion(t))
}

func TestManifestCapabilities_OnlyUserState(t *testing.T) {
func TestManifestCapabilities_UserStateAndAgentInvoke(t *testing.T) {
m := loadManifest(t)
require.Equal(t, map[string]any{"user_state": true}, m.Capabilities)
require.Equal(t, map[string]any{"user_state": true, "agent_invoke": true}, m.Capabilities)
}

func TestManifest_NoWebhooksOrConfigSchema(t *testing.T) {
func TestManifest_DeclaresEnhanceWebhookAndUtilityAgentConfig(t *testing.T) {
m := loadManifest(t)
require.Empty(t, m.Webhooks)
require.Empty(t, m.ConfigSchema)
require.Len(t, m.Webhooks, 1)
webhook, ok := m.Webhooks[0].(map[string]any)
require.True(t, ok, "webhooks[0] should decode as a map")
require.Equal(t, "enhance", webhook["key"])
require.Equal(t, "POST", webhook["method"])

require.NotEmpty(t, m.ConfigSchema)
properties, ok := m.ConfigSchema["properties"].(map[string]any)
require.True(t, ok, "config_schema.properties should decode as a map")
utilityAgent, ok := properties["utility_agent"].(map[string]any)
require.True(t, ok, "config_schema.properties.utility_agent should decode as a map")
require.Equal(t, "string", utilityAgent["type"])
require.Equal(t, "utility-agent", utilityAgent["format"])
}

func TestManifestUI_BundlePathHasLeadingSlash(t *testing.T) {
Expand Down
122 changes: 114 additions & 8 deletions server/plugin.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,125 @@
// Package main implements the backend half of kandev-plugin-notes.
//
// This plugin has no backend behavior: the note itself lives in per-user
// host.storage, a browser-reachable capability with no Go-side counterpart
// (see manifest.yaml's capabilities.user_state and ui/bundle.js). notesPlugin
// exists only because kandev's installer requires runtime.type: binary — it
// declares no events, no webhooks, and no config, and never overrides
// UnimplementedPlugin's no-op RPCs.
// The note itself still lives entirely in per-user host.storage, a
// browser-reachable capability with no Go-side counterpart (see
// manifest.yaml's capabilities.user_state and ui/bundle.js) — this backend
// never reads or writes note content directly. Its one job is the
// "Enhance with AI" feature: relay the note's current markdown to the
// operator-configured utility agent (capabilities.agent_invoke,
// Host.InvokeUtilityAgent) via a single webhook and return the proofread
// markdown, so the frontend never needs its own LLM credentials. OnEvent
// stays the UnimplementedPlugin no-op — this plugin subscribes to no events.
package main

import "github.com/kandev/kandev/pkg/pluginsdk"
import (
"context"
"encoding/json"
"fmt"
"net/http"

"github.com/kandev/kandev/pkg/pluginsdk"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// enhanceWebhookKey is this plugin's single webhook key (manifest.yaml
// webhooks[].key: "enhance"), reachable at
// POST /api/plugins/kandev-plugin-notes/webhooks/enhance.
const enhanceWebhookKey = "enhance"

// enhancePromptTemplate wraps the note's markdown in a proofreading
// instruction for the one-shot utility-agent completion. The agent is asked
// to return markdown only, matching this plugin's storage format, so the
// response can replace the note verbatim (after the UI's preview/accept
// step) with no reformatting.
const enhancePromptTemplate = `You are a proofreading assistant for a short personal note written in Markdown. Improve grammar, spelling, clarity, and formatting while preserving the author's meaning, tone, and structure. Keep the same Markdown syntax (headings, lists, links, code blocks, emphasis) and do not add commentary, explanations, or a preamble — respond with only the improved Markdown note.

Note:
%s`

// enhanceRequestBody is the JSON body accepted by the enhance webhook.
type enhanceRequestBody struct {
Content string `json:"content"`
}

// enhanceResponseBody is the JSON body returned by the enhance webhook on
// success: the proofread markdown, under the same field name the UI sent so
// the client can reuse one shape for the request and response.
type enhanceResponseBody struct {
Content string `json:"content"`
}

// enhanceErrorBody is the JSON body returned on a handled failure (missing
// utility agent, bad input) — a stable {error} shape the UI can surface
// without parsing prose out of a plain-text body.
type enhanceErrorBody struct {
Error string `json:"error"`
}

// notesPlugin implements pluginsdk.Plugin via UnimplementedPlugin's no-op
// defaults. It intentionally overrides nothing.
// defaults for everything except HandleWebhook, which it overrides to
// service the "enhance" key below.
type notesPlugin struct {
pluginsdk.UnimplementedPlugin
}

var _ pluginsdk.Plugin = (*notesPlugin)(nil)

// HandleWebhook services the "enhance" webhook key: POST {content} ->
// {content: <proofread markdown>}. Any other key returns 404, matching
// UnimplementedPlugin's default for undeclared routes. A missing Host,
// wrong method, or empty content are all guarded before ever calling
// Host.InvokeUtilityAgent.
func (p *notesPlugin) HandleWebhook(ctx context.Context, req *pluginsdk.WebhookRequest) (*pluginsdk.WebhookResponse, error) {
if req == nil || req.WebhookKey != enhanceWebhookKey {
return &pluginsdk.WebhookResponse{Status: http.StatusNotFound}, nil
}
if req.Method != http.MethodPost {
return jsonErrorResponse(http.StatusMethodNotAllowed, "enhance requires POST")
}

var body enhanceRequestBody
if err := json.Unmarshal(req.Body, &body); err != nil {
return jsonErrorResponse(http.StatusBadRequest, "invalid request body")
}
if body.Content == "" {
return jsonErrorResponse(http.StatusBadRequest, "content must not be empty")
}

host := p.Host()
if host == nil {
return jsonErrorResponse(http.StatusServiceUnavailable, "plugin host unavailable")
}

improved, err := host.InvokeUtilityAgent(ctx, fmt.Sprintf(enhancePromptTemplate, body.Content))
if err != nil {
if status.Code(err) == codes.FailedPrecondition {
// No utility agent configured (or the configured one was
// deleted/disabled) — a distinguishable, non-fatal condition
// per ADR 0048, not an internal error.
return jsonErrorResponse(http.StatusPreconditionFailed, "no utility agent is configured for this plugin — configure one in Settings > Plugins > Notes")
}
return jsonErrorResponse(http.StatusBadGateway, "AI enhancement failed")
}

return jsonResponse(http.StatusOK, enhanceResponseBody{Content: improved})
}

// jsonResponse marshals body as the JSON WebhookResponse payload for status.
func jsonResponse(status int32, body any) (*pluginsdk.WebhookResponse, error) {
payload, err := json.Marshal(body)
if err != nil {
return jsonErrorResponse(http.StatusInternalServerError, "internal error")
}
return &pluginsdk.WebhookResponse{
Status: status,
Headers: map[string]string{"Content-Type": "application/json"},
Body: payload,
}, nil
}

// jsonErrorResponse is jsonResponse for the {error} shape, used on every
// handled failure path above.
func jsonErrorResponse(status int32, message string) (*pluginsdk.WebhookResponse, error) {
return jsonResponse(status, enhanceErrorBody{Error: message})
}
Loading
Loading