mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
cf5eb8dd3a
`pad item list --format json` returned the full models.Item shape — including each item's rich markdown `content` body (~52% of the bytes) plus UUID plumbing and duplicate join fields — with no default limit, so a bare agent list dumped ~1.4MB (all collections) or 5.3MB (--all) into context. The single biggest agent-token lever. CLI: - JSON output now defaults to a token-light ItemSummary projection: `content` → short `content_preview`, UUIDs (id/workspace_id/collection_id/*_user_id/ parent_id/agent_role_id) and duplicate collection/parent join fields dropped, `fields`/`tags` emitted as nested JSON. ~71% smaller on a real workspace. - `--full` opt-in flag restores the complete models.Item shape. - Default limit (200) + hard-max clamp (1000) so --all/huge lists can't dump unboundedly; a stderr note fires when a table result is capped. MCP: - pad_item.list is now a custom action that injects a default limit (50) and clamps an oversized one (max 300), mirroring the backlinks default/max, so a bare agent list stays bounded on both dispatchers. - ToolSurfaceVersion 0.8 → 0.9 (list result shape + limit behavior change). Server: - Hard-max backstop clamp (1000) on an explicit `?limit=` at the item-list request boundary; no default (internal ListItems callers that fetch every row are untouched). rawJSONOrNil guards against a malformed stored Fields/Tags value breaking the whole list marshal (falls back to a JSON string).
127 lines
4.7 KiB
Go
127 lines
4.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// ItemSummary is the agent-friendly, token-light projection of
|
|
// models.Item used by `pad item list` JSON output by default (TASK-2000).
|
|
//
|
|
// It drops the single biggest cost in a list response — the full rich-text
|
|
// `content` body, which is ~half the bytes of a typical `item list --format
|
|
// json` — replacing it with a short `content_preview`. It also drops the
|
|
// redundant UUID plumbing (id / workspace_id / collection_id / *_user_id /
|
|
// parent_id / agent_role_id) and the duplicate collection/parent join fields
|
|
// that only repeat data already addressable via slugs/refs. `fields` and
|
|
// `tags` are emitted as nested JSON (not escaped strings) so agents can read
|
|
// them without a second parse.
|
|
//
|
|
// The full shape (raw models.Item) is still available via `item list --full`.
|
|
type ItemSummary struct {
|
|
Ref string `json:"ref,omitempty"`
|
|
Title string `json:"title"`
|
|
Slug string `json:"slug"`
|
|
CollectionSlug string `json:"collection_slug,omitempty"`
|
|
ItemNumber *int `json:"item_number,omitempty"`
|
|
Fields json.RawMessage `json:"fields,omitempty"`
|
|
Tags json.RawMessage `json:"tags,omitempty"`
|
|
Pinned bool `json:"pinned,omitempty"`
|
|
ContentPreview string `json:"content_preview,omitempty"`
|
|
ParentRef string `json:"parent_ref,omitempty"`
|
|
AssignedUser string `json:"assigned_user,omitempty"`
|
|
AgentRole string `json:"agent_role,omitempty"`
|
|
HasChildren bool `json:"has_children,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// contentPreviewLimit caps the content_preview at a small, agent-friendly
|
|
// length. A preview exists so an agent can recognize an item without pulling
|
|
// the whole body; the full body is one `pad item show <ref>` away.
|
|
const contentPreviewLimit = 200
|
|
|
|
// contentPreview returns the first non-empty line of the body (markdown
|
|
// stripped of a leading heading marker), truncated to contentPreviewLimit
|
|
// runes with an ellipsis when longer. Empty content yields "".
|
|
func contentPreview(content string) string {
|
|
trimmed := strings.TrimSpace(content)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
// Take the first non-blank line so the preview is a coherent snippet
|
|
// rather than a mid-sentence cut across a blank line.
|
|
line := trimmed
|
|
for _, l := range strings.Split(trimmed, "\n") {
|
|
if s := strings.TrimSpace(l); s != "" {
|
|
line = s
|
|
break
|
|
}
|
|
}
|
|
// Drop a leading markdown heading marker for readability.
|
|
line = strings.TrimLeft(line, "#")
|
|
line = strings.TrimSpace(line)
|
|
|
|
runes := []rune(line)
|
|
if len(runes) > contentPreviewLimit {
|
|
return strings.TrimSpace(string(runes[:contentPreviewLimit])) + "…"
|
|
}
|
|
return line
|
|
}
|
|
|
|
// rawJSONOrNil returns s as json.RawMessage when it is a non-empty, non-empty-
|
|
// container JSON value, else nil (so the field is omitted). Guards against
|
|
// json.RawMessage("") — which is invalid JSON — and drops the noise of empty
|
|
// "{}" / "[]" so the summary stays lean.
|
|
func rawJSONOrNil(s string) json.RawMessage {
|
|
t := strings.TrimSpace(s)
|
|
if t == "" || t == "{}" || t == "[]" || t == "null" {
|
|
return nil
|
|
}
|
|
// Defensive: item.Fields/Tags are validated JSON on write, but a
|
|
// malformed stored value must never break the whole list marshal
|
|
// (json.Marshal of an invalid RawMessage errors). Fall back to
|
|
// emitting the raw value as a JSON string so output stays valid.
|
|
if !json.Valid([]byte(t)) {
|
|
if b, err := json.Marshal(t); err == nil {
|
|
return json.RawMessage(b)
|
|
}
|
|
return nil
|
|
}
|
|
return json.RawMessage(t)
|
|
}
|
|
|
|
// ToItemSummary projects a single models.Item into the summary shape.
|
|
func ToItemSummary(item models.Item) ItemSummary {
|
|
return ItemSummary{
|
|
Ref: item.Ref,
|
|
Title: item.Title,
|
|
Slug: item.Slug,
|
|
CollectionSlug: item.CollectionSlug,
|
|
ItemNumber: item.ItemNumber,
|
|
Fields: rawJSONOrNil(item.Fields),
|
|
Tags: rawJSONOrNil(item.Tags),
|
|
Pinned: item.Pinned,
|
|
ContentPreview: contentPreview(item.Content),
|
|
ParentRef: item.ParentRef,
|
|
AssignedUser: item.AssignedUserName,
|
|
AgentRole: item.AgentRoleSlug,
|
|
HasChildren: item.HasChildren,
|
|
CreatedAt: item.CreatedAt,
|
|
UpdatedAt: item.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
// ToItemSummaries projects a slice of items into summary shape, preserving
|
|
// order. A nil input yields a non-nil empty slice so the JSON encodes as `[]`.
|
|
func ToItemSummaries(items []models.Item) []ItemSummary {
|
|
out := make([]ItemSummary, 0, len(items))
|
|
for _, it := range items {
|
|
out = append(out, ToItemSummary(it))
|
|
}
|
|
return out
|
|
}
|