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
3 changes: 3 additions & 0 deletions lib/egghead.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ defmodule Egghead do
* `:class` — `:durable`, `:inbox`, `:deliberation`, `:transcript`,
`:agent`, or `:skill` (default: `:durable`)
* `:body` — the record body text
* `:format` — `:markdown` (default) or `:org`. Determines the on-disk
format and the file extension (`.md` or `.org`). The system default
can be overridden via `default_format:` in `config.yml`.
"""
@spec create_record(map()) :: {:ok, Record.t()} | {:error, term()}
defdelegate create_record(attrs), to: RecordStore
Expand Down
1 change: 1 addition & 0 deletions lib/egghead/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ defmodule Egghead.Application do
Application.put_env(:egghead, :records_dir, Egghead.Config.records_dir(config))
Application.put_env(:egghead, :skills_dir, Egghead.Config.skills_dir(config))
Application.put_env(:egghead, :mcp_servers, config.mcp_servers)
Application.put_env(:egghead, :default_format, config.default_format || :markdown)

if config.server do
Application.put_env(:egghead, :server, config.server)
Expand Down
62 changes: 55 additions & 7 deletions lib/egghead/capability.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,13 @@ defmodule Egghead.Capability do
Parses a frontmatter `capabilities:` value into a list of grants.

Accepts:
- A list of strings: `["records.read", "agent.create"]`
- A list mixing strings and maps (scoped): `["records.read", %{"net.get" => %{"hosts" => [...]}}]`
- A comma/space-separated string: `"records.read agent.create"`
- A list of strings: `["records.read", "agent.create"]`.
- A list mixing strings and maps (scoped): `["records.read", %{"net.get" => %{"hosts" => [...]}}]`.
- A comma/space-separated string: `"records.read agent.create"`.
- Tokens in the same compact spec form `egghead agent grant` accepts:
`"net.get{hosts=[*.github.com]} records.read"`. Useful in contexts
where the YAML nested-map form isn't available — org property
drawers, CLI input, environment variables.

Unknown strings are logged and dropped (forward-compat for skills
referencing future capability names).
Expand All @@ -55,7 +59,7 @@ defmodule Egghead.Capability do

def parse(str) when is_binary(str) do
str
|> String.split(~r/[,\s]+/, trim: true)
|> tokenize()
|> parse()
end

Expand All @@ -64,10 +68,54 @@ defmodule Egghead.Capability do
[]
end

@doc """
Split a capabilities string into tokens, respecting `{...}` scope
bodies. Whitespace and commas separate top-level tokens; commas inside
a scope body are part of its value list, not a token boundary.

Public so other layers (the agent record projection) can pre-tokenize
consistently when their input is also a flat string.
"""
@spec tokenize(String.t()) :: [String.t()]
def tokenize(str) do
{tokens, current, depth} =
str
|> String.graphemes()
|> Enum.reduce({[], "", 0}, fn
c, {acc, cur, d} when c in ["{", "["] ->
{acc, cur <> c, d + 1}

c, {acc, cur, d} when c in ["}", "]"] ->
{acc, cur <> c, max(d - 1, 0)}

ch, {acc, cur, 0} when ch in [" ", "\t", "\n", ","] ->
if cur == "", do: {acc, "", 0}, else: {[cur | acc], "", 0}

ch, {acc, cur, d} ->
{acc, cur <> ch, d}
end)

tokens = if current == "", do: tokens, else: [current | tokens]

if depth > 0,
do: Logger.warning("Capability.parse: unbalanced brackets in #{inspect(str)}")

Enum.reverse(tokens)
end

defp parse_one(str) when is_binary(str) do
case split_resource_verb(str) do
{:ok, resource, verb} -> [%Grant{resource: resource, verb: verb, scope: %{}}]
:error -> warn_drop(str)
case parse_grant_spec(str) do
{:ok, bare} when is_binary(bare) ->
case split_resource_verb(bare) do
{:ok, resource, verb} -> [%Grant{resource: resource, verb: verb, scope: %{}}]
:error -> warn_drop(str)
end

{:ok, %{} = scoped_map} ->
parse_one(scoped_map)

{:error, _} ->
warn_drop(str)
end
end

Expand Down
17 changes: 17 additions & 0 deletions lib/egghead/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ defmodule Egghead.Config do
llm: [],
default_model: nil,
default_room: nil,
default_format: :markdown,
theme: "terminal-dark",
web: %{port: 4000, host: "localhost", bind: "127.0.0.1"},
mcp_servers: [],
Expand Down Expand Up @@ -65,6 +66,7 @@ defmodule Egghead.Config do
llm: [llm_entry()],
default_model: String.t() | nil,
default_room: String.t() | nil,
default_format: :markdown | :org,
theme: String.t(),
web: %{port: non_neg_integer(), host: String.t(), bind: String.t()},
mcp_servers: [mcp_server()]
Expand Down Expand Up @@ -274,6 +276,7 @@ defmodule Egghead.Config do
llm: parse_llm(data["llm"]),
default_model: data["default_model"],
default_room: data["default_room"],
default_format: parse_format(data["default_format"]),
theme: data["theme"] || "terminal-dark",
web: parse_web(data["web"]),
mcp_servers: parse_mcp_servers(data["mcp_servers"]),
Expand Down Expand Up @@ -321,6 +324,14 @@ defmodule Egghead.Config do

defp parse_mcp_server(_), do: :error

# Default record format. `org` enables full org-mode treatment for newly
# created records; existing files keep their on-disk format regardless.
defp parse_format("org"), do: :org
defp parse_format(:org), do: :org
defp parse_format("markdown"), do: :markdown
defp parse_format(:markdown), do: :markdown
defp parse_format(_), do: :markdown

defp parse_transport("stdio"), do: :stdio
defp parse_transport("http"), do: :http
defp parse_transport(:stdio), do: :stdio
Expand Down Expand Up @@ -414,6 +425,7 @@ defmodule Egghead.Config do
emit_llm(config.llm),
emit_field("default_model", config.default_model),
emit_field("default_room", config.default_room),
emit_format(config.default_format),
emit_field("theme", config.theme),
emit_web(config.web),
emit_mcp_servers(config.mcp_servers)
Expand Down Expand Up @@ -493,6 +505,11 @@ defmodule Egghead.Config do
defp emit_field(_key, nil), do: nil
defp emit_field(key, value), do: "#{key}: #{yaml_escape(value)}"

# Markdown is the implicit default; only emit when the user has opted into
# org. Keeps untouched config files clean.
defp emit_format(:org), do: "default_format: org"
defp emit_format(_), do: nil

defp emit_llm([]), do: nil

defp emit_llm(entries) do
Expand Down
25 changes: 20 additions & 5 deletions lib/egghead/mcp/handler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ defmodule Egghead.MCP.Handler do
%{
name: "egghead_create",
description:
"Create a new record. Writes a Markdown file to the records directory. Returns the created record.",
"Create a new record. Writes a Markdown (.md) or org-mode (.org) file to the records directory based on the `format` field (default: markdown, or whatever `default_format` is set to in config.yml). Returns the created record.",
inputSchema: %{
type: "object",
properties: %{
Expand All @@ -146,15 +146,25 @@ defmodule Egghead.MCP.Handler do
enum: ["durable", "inbox", "deliberation"],
description: "Record class (default: durable)"
},
body: %{type: "string", description: "Record body content (Markdown)"}
format: %{
type: "string",
enum: ["markdown", "org"],
description:
"On-disk format. `markdown` writes a `.md` file with YAML frontmatter; `org` writes a `.org` file with `#+`-keywords and a properties drawer. Defaults to the configured `default_format` (markdown if unset)."
},
body: %{
type: "string",
description:
"Record body content. Use Markdown for `.md` records or org syntax for `.org` records. For `.org`, the body is the full file content — `#+`-keywords and property drawers are part of the document."
}
},
required: ["title"]
}
},
%{
name: "egghead_update",
description:
"Update an existing record. Merges the given fields into the record. Omitted fields are left unchanged. To replace the body entirely, pass the full new body.",
"Update an existing record. Merges the given fields into the record. Omitted fields are left unchanged. The on-disk format is preserved — for `.org` files the change is line-spliced (the rest of the file stays byte-identical), so it's safe to update one field at a time without re-writing the whole document. To replace the body entirely, pass the full new body.",
inputSchema: %{
type: "object",
properties: %{
Expand All @@ -170,7 +180,11 @@ defmodule Egghead.MCP.Handler do
items: %{type: "string"},
description: "New linked record ids (replaces existing links)"
},
body: %{type: "string", description: "New body content (Markdown)"}
body: %{
type: "string",
description:
"New body content. For org records, this is the full file content — write back exactly what you read."
}
},
required: ["id"]
}
Expand Down Expand Up @@ -321,7 +335,7 @@ defmodule Egghead.MCP.Handler do
defp call_tool("egghead_create", args) do
attrs =
args
|> Map.take(["id", "title", "tags", "links", "class", "body"])
|> Map.take(["id", "title", "tags", "links", "class", "body", "format"])
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()

Expand Down Expand Up @@ -530,6 +544,7 @@ defmodule Egghead.MCP.Handler do
if(record.created, do: "created: #{record.created}"),
if(record.updated, do: "updated: #{record.updated}"),
"class: #{record.class}",
"format: #{record.format}",
if(record.tags != [], do: "tags: #{Enum.join(record.tags, ", ")}"),
references_line(record),
if(record.meta != %{}, do: "meta: #{Jason.encode!(record.meta)}")
Expand Down
16 changes: 11 additions & 5 deletions lib/egghead/open_tui/markdown.ex
Original file line number Diff line number Diff line change
Expand Up @@ -606,16 +606,22 @@ defmodule Egghead.OpenTUI.Markdown do

# ---- span-aware word wrap ----------------------------------------------

# Wrap a list of spans into rows of <= width columns. Hard
# newlines (from <br> and embedded "\n") force a row break;
# everything else is greedy-fill at word boundaries.
defp wrap_spans(spans, width) when width > 0 do
@doc """
Wrap a list of spans into rows of `<= width` columns. Hard newlines
(from `<br>` or embedded `"\n"`) force a row break; everything else is
greedy-fill at word boundaries.

Public so any other renderer producing the same span shape can reuse
the same wrap behavior.
"""
@spec wrap_spans([span()], pos_integer()) :: rendered()
def wrap_spans(spans, width) when width > 0 do
spans
|> tokenize()
|> fill_rows(width)
end

defp wrap_spans(_spans, _width), do: [[]]
def wrap_spans(_spans, _width), do: [[]]

# Convert spans to a flat token stream of:
# {:word, span} — non-whitespace run, atomic
Expand Down
19 changes: 17 additions & 2 deletions lib/egghead/record/agent.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ defmodule Egghead.Record.Agent do
alias Egghead.Capability
alias Egghead.LLM.Registry
alias Egghead.Record
alias Egghead.Record.OrgParser

@default_context_threshold 0.70
@default_max_tokens 4096
Expand Down Expand Up @@ -73,7 +74,7 @@ defmodule Egghead.Record.Agent do
%__MODULE__{
id: record.id,
name: record.title || record.id,
disposition: record.body || "",
disposition: disposition_from(record),
model: resolve_model(record),
provider: meta_string(record, "provider"),
capabilities: parse_capabilities(record),
Expand Down Expand Up @@ -233,11 +234,25 @@ defmodule Egghead.Record.Agent do

defp normalize_caps(nil), do: []
defp normalize_caps(list) when is_list(list), do: list
defp normalize_caps(str) when is_binary(str), do: String.split(str, ~r/[,\s]+/, trim: true)
# Use the paren-aware tokenizer so textual scope syntax —
# `net.get(hosts=a,b)` — survives intact through to `Capability.parse/1`.
defp normalize_caps(str) when is_binary(str), do: Capability.tokenize(str)
defp normalize_caps(_), do: []

# --- Helpers ---

# The disposition is the prose body of the agent record — what gets
# injected into the model's system prompt. For markdown agents `body`
# is already post-frontmatter so we use it directly. For org agents
# `body` is the full file content (the org rule: file == document),
# so we strip the file-level keyword block and properties drawer for
# the prompt projection. The on-disk file is unchanged; this is just
# what the model sees.
defp disposition_from(%Record{format: :org, body: body}) when is_binary(body),
do: OrgParser.body_without_preamble(body)

defp disposition_from(%Record{body: body}), do: body || ""

defp resolve_model(%Record{meta: meta}) do
raw = meta_string_value(meta["model"])
provider = meta_string_value(meta["provider"])
Expand Down
67 changes: 66 additions & 1 deletion lib/egghead/record/org_parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,66 @@ defmodule Egghead.Record.OrgParser do
collect_links(ast, []) |> Enum.reverse()
end

@doc """
Returns the body of an org file with the file-level preamble stripped:
the contiguous block of `#+`-keywords and `#`-comments at the top, and
the first `:PROPERTIES:` drawer if it appears before any headline.

Used to project an org agent record's body into a system prompt: the
on-disk file keeps every `#+TITLE:` and drawer entry (those are the
document), but the prompt the model sees is the prose part starting
from the first real content.

This is a *projection*; the source file is unchanged.
"""
@spec body_without_preamble(String.t()) :: String.t()
def body_without_preamble(content) when is_binary(content) do
lines = String.split(content, "\n")
{_skipped, rest} = drop_keyword_prefix(lines)
{_drawer, rest} = drop_leading_drawer(rest)

rest
|> Enum.drop_while(&blank?/1)
|> Enum.join("\n")
|> String.trim_trailing()
end

defp drop_keyword_prefix(lines) do
Enum.split_while(lines, fn line ->
Regex.match?(~r/^[ \t]*#\+\w+:/, line) or
Regex.match?(~r/^[ \t]*#[^+]/, line) or
blank?(line)
end)
end

# If the next non-blank line opens a properties drawer (and no headline
# has appeared), consume up to the matching :END:. Headline-attached
# drawers belong to that subtree and are left in place.
defp drop_leading_drawer(lines) do
{leading_blanks, rest} = Enum.split_while(lines, &blank?/1)

case rest do
[first | tail] ->
if Regex.match?(~r/^[ \t]*:PROPERTIES:[ \t]*$/, first) do
case Enum.split_while(tail, fn l -> not Regex.match?(~r/^[ \t]*:END:[ \t]*$/, l) end) do
{drawer_lines, [end_line | after_end]} ->
{[first | drawer_lines] ++ [end_line], after_end}

{_drawer_lines, []} ->
# Unterminated drawer — leave content alone.
{[], leading_blanks ++ rest}
end
else
{[], leading_blanks ++ rest}
end

[] ->
{[], leading_blanks}
end
end

defp blank?(line), do: Regex.match?(~r/^\s*$/, line)

@doc """
Extracts all source blocks from the AST.
"""
Expand Down Expand Up @@ -264,7 +324,12 @@ defmodule Egghead.Record.OrgParser do

true ->
{para_lines, rest} = collect_paragraph([line | rest], [])
inline = parse_inline_text(Enum.join(para_lines, "\n"))
# Join with a single space so the inline parser (whose tokens
# exclude `\n`) sees one logical line. Org treats line breaks
# within a paragraph as soft — they render as spaces, like
# markdown — so collapsing is correct, and it lets bold/italic/
# link parsing work across visually-wrapped source lines.
inline = parse_inline_text(Enum.map_join(para_lines, " ", &String.trim_trailing/1))
node = {:paragraph, %{}, inline}
parse_lines(rest, [node | acc], state)
end
Expand Down
Loading
Loading