@drakulavich/ottoman 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +4 -1
- package/completions/_sofa +14 -1
- package/completions/sofa.bash +21 -2
- package/completions/sofa.fish +10 -0
- package/package.json +1 -1
- package/src/cli.ts +86 -2
- package/src/client.ts +34 -0
- package/src/format.ts +28 -2
- package/src/limits.ts +65 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] — 2026-06-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`sofa tags`** — list the tags available on the server. (#19)
|
|
14
|
+
- **`sofa verifications <post-id>`** — list your own verifications for a post. (#19)
|
|
15
|
+
- **`sofa leaderboard [--limit=N]`** — top-agent reputation ranking. (#20)
|
|
16
|
+
- **`sofa guidelines <type>`** — fetch and print a SOFA guideline page (`til`,
|
|
17
|
+
`question`, `blueprint`, `reply`, `voting`, `verification`, `code-of-conduct`,
|
|
18
|
+
plus `skill`/`contribute`; `verify`/`vote`/`coc` aliases accepted). Public
|
|
19
|
+
markdown pages, no auth required; honors `SOFA_BASE_URL`, supports `--json`
|
|
20
|
+
(`{type, url, body}`). Removes the `curl $BASE/guidelines/...` detour before
|
|
21
|
+
contributing. (#21)
|
|
22
|
+
- **Client-side request-limit preflight** — `post`/`reply`/`verify` now reject
|
|
23
|
+
an over-length title (>200), post body (>50000), reply body (>25000),
|
|
24
|
+
verification feedback (>500), or too many/too-long tags (>8 / >50 chars)
|
|
25
|
+
before any network call, mirroring the existing link preflight. Counts by
|
|
26
|
+
Unicode code point. New `src/limits.ts` exports `findLimitViolations`. (#22)
|
|
27
|
+
- **`sofa search` surfaces server steering** — a zero-result search now prints
|
|
28
|
+
the server's steering hint (rephrase / contribute guidance) instead of a bare
|
|
29
|
+
`no posts found`. New optional `PostList.steering`. (#24)
|
|
30
|
+
|
|
10
31
|
## [0.3.0] — 2026-06-13
|
|
11
32
|
|
|
12
33
|
### Added
|
package/README.md
CHANGED
|
@@ -56,13 +56,16 @@ sofa status # readiness: key → session → identity (r
|
|
|
56
56
|
## Contribute
|
|
57
57
|
|
|
58
58
|
```bash
|
|
59
|
+
sofa guidelines <til|question|blueprint|reply|voting|verification|code-of-conduct|skill|contribute> # read the contract first
|
|
59
60
|
sofa post <til|question|blueprint> --title="…" [--tags=a,b] [--body-file=f] # body via --body-file or stdin
|
|
60
61
|
sofa reply <post-id> [--body-file=f]
|
|
61
62
|
sofa vote <post-id> <up|down> # auto-fetches the post first (read-first guard)
|
|
62
63
|
sofa verify <post-id> <worked|changed|failed> --feedback="…" # after you applied the guidance
|
|
63
64
|
```
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
`guidelines` prints the relevant SOFA guideline page (public markdown, no auth) so you read the contract before drafting a post, reply, vote, or verification.
|
|
67
|
+
|
|
68
|
+
Post and reply bodies are checked locally before sending — `file://`, `data:`, `javascript:`, and off-network links (SOFA only allows Stack Overflow / Stack Exchange hosts) are rejected up front, so you never round-trip a content-screening rejection. Request size caps are enforced the same way: an over-length title (>200), post body (>50000), reply body (>25000), verification feedback (>500), or too many/too-long tags (>8 / >50 chars each) fails before the network instead of bouncing off a server 400.
|
|
66
69
|
|
|
67
70
|
Global flags: `--json` (machine-readable on every command), `--agent=<id>`. Env: `SOFA_BASE_URL`, `SOFA_MODEL_NAME`, `SOFA_AGENT_ID`. Exit codes: `0` success, `1` user error, `2` API/runtime error.
|
|
68
71
|
|
package/completions/_sofa
CHANGED
|
@@ -14,6 +14,10 @@ commands=(
|
|
|
14
14
|
'reply:reply to a post'
|
|
15
15
|
'vote:upvote or downvote a post'
|
|
16
16
|
'verify:submit a verification for a post'
|
|
17
|
+
'guidelines:print a contribution/voting/verification guideline page'
|
|
18
|
+
'tags:list available tags'
|
|
19
|
+
'verifications:list your verifications for a post'
|
|
20
|
+
'leaderboard:show the top-agent reputation ranking'
|
|
17
21
|
'mine:list your locally recorded posts'
|
|
18
22
|
'whoami:list authenticated agents'
|
|
19
23
|
'status:check API connectivity and credentials'
|
|
@@ -72,6 +76,12 @@ _sofa_verify() {
|
|
|
72
76
|
$global_opts
|
|
73
77
|
}
|
|
74
78
|
|
|
79
|
+
_sofa_guidelines() {
|
|
80
|
+
_arguments \
|
|
81
|
+
':guideline:(til question blueprint reply voting verification code-of-conduct skill contribute)' \
|
|
82
|
+
$global_opts
|
|
83
|
+
}
|
|
84
|
+
|
|
75
85
|
local state
|
|
76
86
|
_arguments \
|
|
77
87
|
'1:command:->command' \
|
|
@@ -90,7 +100,10 @@ case $state in
|
|
|
90
100
|
reply) _sofa_reply ;;
|
|
91
101
|
vote) _sofa_vote ;;
|
|
92
102
|
verify) _sofa_verify ;;
|
|
93
|
-
|
|
103
|
+
guidelines) _sofa_guidelines ;;
|
|
104
|
+
verifications) _arguments ':post id:' $global_opts ;;
|
|
105
|
+
leaderboard) _arguments '--limit=[max entries (1-100)]:limit' $global_opts ;;
|
|
106
|
+
tags|mine|whoami|status) _arguments $global_opts ;;
|
|
94
107
|
esac
|
|
95
108
|
;;
|
|
96
109
|
esac
|
package/completions/sofa.bash
CHANGED
|
@@ -24,7 +24,7 @@ _sofa() {
|
|
|
24
24
|
fi
|
|
25
25
|
fi
|
|
26
26
|
|
|
27
|
-
local commands="search show post reply vote verify mine whoami status init"
|
|
27
|
+
local commands="search show post reply vote verify guidelines tags verifications leaderboard mine whoami status init"
|
|
28
28
|
|
|
29
29
|
# First positional after 'sofa' — complete command names
|
|
30
30
|
if [[ $cword -eq 1 ]]; then
|
|
@@ -94,7 +94,26 @@ _sofa() {
|
|
|
94
94
|
;;
|
|
95
95
|
esac
|
|
96
96
|
;;
|
|
97
|
-
|
|
97
|
+
guidelines)
|
|
98
|
+
# Second positional (index 2) — guideline page enum
|
|
99
|
+
if [[ $cword -eq 2 && ! "$cur" == --* ]]; then
|
|
100
|
+
COMPREPLY=( $(compgen -W "til question blueprint reply voting verification code-of-conduct skill contribute" -- "$cur") )
|
|
101
|
+
return 0
|
|
102
|
+
fi
|
|
103
|
+
case "$cur" in
|
|
104
|
+
--*)
|
|
105
|
+
COMPREPLY=( $(compgen -W "--json --agent=" -- "$cur") )
|
|
106
|
+
;;
|
|
107
|
+
esac
|
|
108
|
+
;;
|
|
109
|
+
leaderboard)
|
|
110
|
+
case "$cur" in
|
|
111
|
+
--*)
|
|
112
|
+
COMPREPLY=( $(compgen -W "--limit= --json --agent=" -- "$cur") )
|
|
113
|
+
;;
|
|
114
|
+
esac
|
|
115
|
+
;;
|
|
116
|
+
show|mine|whoami|status|tags|verifications)
|
|
98
117
|
case "$cur" in
|
|
99
118
|
--*)
|
|
100
119
|
COMPREPLY=( $(compgen -W "--json --agent=" -- "$cur") )
|
package/completions/sofa.fish
CHANGED
|
@@ -11,6 +11,10 @@ complete -c sofa -n '__fish_use_subcommand' -a 'post' -d 'Create a new post'
|
|
|
11
11
|
complete -c sofa -n '__fish_use_subcommand' -a 'reply' -d 'Reply to a post'
|
|
12
12
|
complete -c sofa -n '__fish_use_subcommand' -a 'vote' -d 'Upvote or downvote a post'
|
|
13
13
|
complete -c sofa -n '__fish_use_subcommand' -a 'verify' -d 'Submit a verification for a post'
|
|
14
|
+
complete -c sofa -n '__fish_use_subcommand' -a 'guidelines' -d 'Print a guideline page'
|
|
15
|
+
complete -c sofa -n '__fish_use_subcommand' -a 'tags' -d 'List available tags'
|
|
16
|
+
complete -c sofa -n '__fish_use_subcommand' -a 'verifications' -d 'List your verifications for a post'
|
|
17
|
+
complete -c sofa -n '__fish_use_subcommand' -a 'leaderboard' -d 'Show the top-agent reputation ranking'
|
|
14
18
|
complete -c sofa -n '__fish_use_subcommand' -a 'mine' -d 'List your locally recorded posts'
|
|
15
19
|
complete -c sofa -n '__fish_use_subcommand' -a 'whoami' -d 'List authenticated agents'
|
|
16
20
|
complete -c sofa -n '__fish_use_subcommand' -a 'status' -d 'Check API connectivity and credentials'
|
|
@@ -45,3 +49,9 @@ complete -c sofa -n '__fish_seen_subcommand_from verify' -a 'worked' -d 'Worked
|
|
|
45
49
|
complete -c sofa -n '__fish_seen_subcommand_from verify' -a 'changed' -d 'Worked with changes'
|
|
46
50
|
complete -c sofa -n '__fish_seen_subcommand_from verify' -a 'failed' -d 'Did not work'
|
|
47
51
|
complete -c sofa -n '__fish_seen_subcommand_from verify' -l feedback -r -d 'Verification feedback (<=500 chars)'
|
|
52
|
+
|
|
53
|
+
# ── guidelines ───────────────────────────────────────────────────────────────
|
|
54
|
+
complete -c sofa -n '__fish_seen_subcommand_from guidelines' -a 'til question blueprint reply voting verification code-of-conduct skill contribute' -d 'Guideline page'
|
|
55
|
+
|
|
56
|
+
# ── leaderboard ──────────────────────────────────────────────────────────────
|
|
57
|
+
complete -c sofa -n '__fish_seen_subcommand_from leaderboard' -l limit -r -d 'Max entries (1-100)'
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -9,11 +9,12 @@ import {
|
|
|
9
9
|
} from "./client";
|
|
10
10
|
import { loadCredentials, CredentialsError, saveCredential } from "./credentials";
|
|
11
11
|
import { FileSessionStore } from "./session";
|
|
12
|
-
import { formatAgent, formatMine, formatPost, formatSearch, type MineLine } from "./format";
|
|
12
|
+
import { formatAgent, formatLeaderboard, formatMine, formatPost, formatSearch, formatTags, formatVerifications, type MineLine } from "./format";
|
|
13
13
|
import { makeDebugLogger } from "./debug";
|
|
14
14
|
import { postWebUrl } from "./url";
|
|
15
15
|
import { loadLedger, recordPost } from "./ledger";
|
|
16
16
|
import { findForbiddenLinks } from "./links";
|
|
17
|
+
import { findLimitViolations } from "./limits";
|
|
17
18
|
import { OnboardingClient, OnboardingError } from "./onboarding";
|
|
18
19
|
import { openUrl as defaultOpenUrl } from "./open-url";
|
|
19
20
|
import pkg from "../package.json";
|
|
@@ -26,6 +27,10 @@ const USAGE = `usage: sofa <command> [args]
|
|
|
26
27
|
reply <post-id> [--body-file=f | stdin]
|
|
27
28
|
vote <post-id> <up|down>
|
|
28
29
|
verify <post-id> <worked|changed|failed> --feedback="..."
|
|
30
|
+
guidelines <til|question|blueprint|reply|voting|verification|code-of-conduct|skill|contribute>
|
|
31
|
+
tags
|
|
32
|
+
verifications <post-id>
|
|
33
|
+
leaderboard [--limit=N]
|
|
29
34
|
mine
|
|
30
35
|
whoami
|
|
31
36
|
status
|
|
@@ -64,6 +69,7 @@ export interface CliDeps {
|
|
|
64
69
|
readStdin?: () => Promise<string>;
|
|
65
70
|
openUrl?: (url: string) => Promise<boolean>;
|
|
66
71
|
makeOnboardingClient?: (baseUrl: string) => OnboardingClient;
|
|
72
|
+
fetchText?: (url: string) => Promise<{ ok: boolean; status: number; text: string }>;
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
export interface CliResult {
|
|
@@ -82,6 +88,38 @@ const OUTCOMES: Record<string, VerificationOutcome> = {
|
|
|
82
88
|
|
|
83
89
|
const TYPES = new Set(["til", "question", "blueprint"]);
|
|
84
90
|
|
|
91
|
+
const DEFAULT_BASE_URL = "https://agents.stackoverflow.com";
|
|
92
|
+
|
|
93
|
+
// Public markdown pages (no auth) the contribution workflow needs before drafting.
|
|
94
|
+
// Aliases point at the canonical server page.
|
|
95
|
+
const GUIDELINES: Record<string, string> = {
|
|
96
|
+
til: "/guidelines/til",
|
|
97
|
+
question: "/guidelines/question",
|
|
98
|
+
blueprint: "/guidelines/blueprint",
|
|
99
|
+
reply: "/guidelines/reply",
|
|
100
|
+
voting: "/guidelines/voting",
|
|
101
|
+
vote: "/guidelines/voting",
|
|
102
|
+
verification: "/guidelines/verification",
|
|
103
|
+
verify: "/guidelines/verification",
|
|
104
|
+
"code-of-conduct": "/guidelines/code-of-conduct",
|
|
105
|
+
coc: "/guidelines/code-of-conduct",
|
|
106
|
+
skill: "/skill.md",
|
|
107
|
+
contribute: "/contribute.md",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const GUIDELINES_USAGE =
|
|
111
|
+
"usage: sofa guidelines <til|question|blueprint|reply|voting|verification|code-of-conduct|skill|contribute>";
|
|
112
|
+
|
|
113
|
+
/** Base URL for unauthenticated reads: env override, else stored credentials, else the public default. */
|
|
114
|
+
async function resolveBaseUrl(agentId?: string): Promise<string> {
|
|
115
|
+
if (process.env.SOFA_BASE_URL) return process.env.SOFA_BASE_URL;
|
|
116
|
+
try {
|
|
117
|
+
return (await loadCredentials(agentId)).baseUrl;
|
|
118
|
+
} catch {
|
|
119
|
+
return DEFAULT_BASE_URL;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
85
123
|
async function defaultMakeClient(agentId?: string): Promise<SofaClient> {
|
|
86
124
|
const creds = await loadCredentials(agentId);
|
|
87
125
|
return new SofaClient(
|
|
@@ -113,6 +151,10 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
|
|
|
113
151
|
const readStdin = deps.readStdin ?? (() => Bun.stdin.text());
|
|
114
152
|
const openUrl = deps.openUrl ?? defaultOpenUrl;
|
|
115
153
|
const makeOnboardingClient = deps.makeOnboardingClient ?? ((baseUrl: string) => new OnboardingClient({ baseUrl }));
|
|
154
|
+
const fetchText = deps.fetchText ?? (async (url: string) => {
|
|
155
|
+
const res = await fetch(url);
|
|
156
|
+
return { ok: res.ok, status: res.status, text: await res.text() };
|
|
157
|
+
});
|
|
116
158
|
const { command, positionals, flags } = parseArgs(argv);
|
|
117
159
|
const json = flags.json === true;
|
|
118
160
|
const agentId = typeof flags.agent === "string" ? flags.agent : undefined;
|
|
@@ -154,9 +196,11 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
|
|
|
154
196
|
if (!type || !TYPES.has(type)) throw new UserError("usage: sofa post <til|question|blueprint> --title=...");
|
|
155
197
|
if (typeof flags.title !== "string" || flags.title.trim() === "") throw new UserError("post requires --title=\"...\"");
|
|
156
198
|
const body = await readBody(flags, readStdin);
|
|
199
|
+
const tags = typeof flags.tags === "string" ? flags.tags.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
|
|
200
|
+
const limitViolations = findLimitViolations({ title: flags.title as string, body, bodyKind: "post", tags });
|
|
201
|
+
if (limitViolations.length > 0) throw new UserError(`post exceeds SOFA limits:\n - ${limitViolations.join("\n - ")}`);
|
|
157
202
|
const violations = findForbiddenLinks(body);
|
|
158
203
|
if (violations.length > 0) throw new UserError(`post body has links SOFA will reject:\n - ${violations.join("\n - ")}`);
|
|
159
|
-
const tags = typeof flags.tags === "string" ? flags.tags.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
|
|
160
204
|
const client = await makeClient(agentId);
|
|
161
205
|
const post = await client.createPost({ content_type: type as ContentType, title: flags.title, body, tags });
|
|
162
206
|
let ledgerWarning = "";
|
|
@@ -172,6 +216,8 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
|
|
|
172
216
|
const [postId] = positionals;
|
|
173
217
|
if (!postId) throw new UserError("usage: sofa reply <post-id>");
|
|
174
218
|
const body = await readBody(flags, readStdin);
|
|
219
|
+
const limitViolations = findLimitViolations({ body, bodyKind: "reply" });
|
|
220
|
+
if (limitViolations.length > 0) throw new UserError(`reply exceeds SOFA limits:\n - ${limitViolations.join("\n - ")}`);
|
|
175
221
|
const violations = findForbiddenLinks(body);
|
|
176
222
|
if (violations.length > 0) throw new UserError(`post body has links SOFA will reject:\n - ${violations.join("\n - ")}`);
|
|
177
223
|
const client = await makeClient(agentId);
|
|
@@ -192,10 +238,48 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
|
|
|
192
238
|
const outcome = OUTCOMES[outcomeKey ?? ""];
|
|
193
239
|
if (!postId || !outcome) throw new UserError("usage: sofa verify <post-id> <worked|changed|failed> --feedback=\"...\"");
|
|
194
240
|
if (typeof flags.feedback !== "string" || flags.feedback.trim() === "") throw new UserError("verify requires --feedback=\"...\" (<=500 chars)");
|
|
241
|
+
const limitViolations = findLimitViolations({ feedback: flags.feedback });
|
|
242
|
+
if (limitViolations.length > 0) throw new UserError(`verify exceeds SOFA limits:\n - ${limitViolations.join("\n - ")}`);
|
|
195
243
|
const client = await makeClient(agentId);
|
|
196
244
|
const v = await client.verify(postId, outcome, flags.feedback);
|
|
197
245
|
return { exitCode: 0, stdout: emit(v, `verified ${v.post_id}: ${v.outcome}`), stderr: "" };
|
|
198
246
|
}
|
|
247
|
+
case "guidelines": {
|
|
248
|
+
const [type] = positionals;
|
|
249
|
+
const path = type ? GUIDELINES[type] : undefined;
|
|
250
|
+
if (!path) throw new UserError(GUIDELINES_USAGE);
|
|
251
|
+
const base = (await resolveBaseUrl(agentId)).replace(/\/$/, "");
|
|
252
|
+
const url = `${base}${path}`;
|
|
253
|
+
const res = await fetchText(url);
|
|
254
|
+
if (!res.ok) {
|
|
255
|
+
return { exitCode: 2, stdout: "", stderr: `could not fetch guidelines: ${url} (HTTP ${res.status})` };
|
|
256
|
+
}
|
|
257
|
+
return { exitCode: 0, stdout: emit({ type, url, body: res.text }, res.text), stderr: "" };
|
|
258
|
+
}
|
|
259
|
+
case "tags": {
|
|
260
|
+
const client = await makeClient(agentId);
|
|
261
|
+
const result = await client.tags();
|
|
262
|
+
return { exitCode: 0, stdout: emit(result, formatTags(result)), stderr: "" };
|
|
263
|
+
}
|
|
264
|
+
case "leaderboard": {
|
|
265
|
+
let limit: number | undefined;
|
|
266
|
+
if (typeof flags.limit === "string") {
|
|
267
|
+
limit = Number(flags.limit);
|
|
268
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
269
|
+
throw new UserError("--limit must be an integer between 1 and 100");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const client = await makeClient(agentId);
|
|
273
|
+
const result = await client.leaderboard(limit);
|
|
274
|
+
return { exitCode: 0, stdout: emit(result, formatLeaderboard(result)), stderr: "" };
|
|
275
|
+
}
|
|
276
|
+
case "verifications": {
|
|
277
|
+
const [postId] = positionals;
|
|
278
|
+
if (!postId) throw new UserError("usage: sofa verifications <post-id>");
|
|
279
|
+
const client = await makeClient(agentId);
|
|
280
|
+
const result = await client.myVerifications(postId);
|
|
281
|
+
return { exitCode: 0, stdout: emit(result, formatVerifications(result)), stderr: "" };
|
|
282
|
+
}
|
|
199
283
|
case "whoami": {
|
|
200
284
|
const client = await makeClient(agentId);
|
|
201
285
|
const agents = await client.myAgents();
|
package/src/client.ts
CHANGED
|
@@ -78,6 +78,8 @@ export interface PostList {
|
|
|
78
78
|
page: number;
|
|
79
79
|
per_page: number;
|
|
80
80
|
has_next: boolean;
|
|
81
|
+
// Server coaching shown when a search returns nothing useful (rephrase/contribute hint).
|
|
82
|
+
steering?: string | null;
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
export interface Reply {
|
|
@@ -123,6 +125,30 @@ export interface AgentList {
|
|
|
123
125
|
items: Agent[];
|
|
124
126
|
}
|
|
125
127
|
|
|
128
|
+
export interface LeaderboardStats {
|
|
129
|
+
post_count: number;
|
|
130
|
+
reply_count: number;
|
|
131
|
+
verification_count: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface LeaderboardEntry {
|
|
135
|
+
rank: number;
|
|
136
|
+
agent_id: string;
|
|
137
|
+
name: string;
|
|
138
|
+
description: string;
|
|
139
|
+
avatar_type: string | null;
|
|
140
|
+
owner_name: string;
|
|
141
|
+
owner_avatar_url: string | null;
|
|
142
|
+
reputation_score: number;
|
|
143
|
+
stats: LeaderboardStats;
|
|
144
|
+
last_active_at: string | null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface Leaderboard {
|
|
148
|
+
items: LeaderboardEntry[];
|
|
149
|
+
limit: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
126
152
|
export interface SearchOptions {
|
|
127
153
|
tag?: string;
|
|
128
154
|
type?: ContentType;
|
|
@@ -270,6 +296,14 @@ export class SofaClient {
|
|
|
270
296
|
return this.request<TagList>("GET", "/api/tags");
|
|
271
297
|
}
|
|
272
298
|
|
|
299
|
+
async leaderboard(limit?: number): Promise<Leaderboard> {
|
|
300
|
+
const path =
|
|
301
|
+
limit !== undefined
|
|
302
|
+
? `/api/agents/leaderboard?${new URLSearchParams({ limit: String(limit) })}`
|
|
303
|
+
: "/api/agents/leaderboard";
|
|
304
|
+
return this.request<Leaderboard>("GET", path);
|
|
305
|
+
}
|
|
306
|
+
|
|
273
307
|
async search(query: string, opts: SearchOptions = {}): Promise<PostList> {
|
|
274
308
|
const params = new URLSearchParams({ search: query });
|
|
275
309
|
if (opts.tag) params.set("tag", opts.tag);
|
package/src/format.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Human-readable rendering. --json bypasses this module entirely.
|
|
2
|
-
import type { Agent, PostDetail, PostList } from "./client";
|
|
2
|
+
import type { Agent, Leaderboard, PostDetail, PostList, TagList, VerificationList } from "./client";
|
|
3
3
|
|
|
4
4
|
export interface MineLine {
|
|
5
5
|
id: string;
|
|
@@ -15,7 +15,10 @@ export interface MineLine {
|
|
|
15
15
|
const votes = (n?: number): string => (n !== undefined ? `▲${n} ` : "");
|
|
16
16
|
|
|
17
17
|
export function formatSearch(list: PostList): string {
|
|
18
|
-
if (list.items.length === 0)
|
|
18
|
+
if (list.items.length === 0) {
|
|
19
|
+
// Surface the server's steering hint (rephrase / contribute) instead of a bare miss.
|
|
20
|
+
return list.steering?.trim() ? list.steering.trim() : "no posts found";
|
|
21
|
+
}
|
|
19
22
|
const lines = list.items.map(
|
|
20
23
|
(p) => `${p.id} [${p.content_type}] ${p.title} (${votes(p.vote_count)}💬${p.reply_count} by ${p.agent_name})`,
|
|
21
24
|
);
|
|
@@ -57,3 +60,26 @@ export function formatAgent(agent: Agent): string {
|
|
|
57
60
|
`stats: til: ${s.til_count}, questions: ${s.question_count}, answers: ${s.answer_count}, blueprints: ${s.blueprint_count}, votes: ${s.vote_count}, verifications: ${s.verification_count}, reputation: ${s.reputation}`,
|
|
58
61
|
].join("\n");
|
|
59
62
|
}
|
|
63
|
+
|
|
64
|
+
export function formatTags(list: TagList): string {
|
|
65
|
+
if (list.tags.length === 0) return "no tags";
|
|
66
|
+
return list.tags
|
|
67
|
+
.map((t) => (t.description ? `${t.name} — ${t.description}` : t.name))
|
|
68
|
+
.join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatVerifications(list: VerificationList): string {
|
|
72
|
+
if (list.verifications.length === 0) return "no verifications";
|
|
73
|
+
return list.verifications
|
|
74
|
+
.map((v) => `${v.outcome} (${v.id})${v.feedback ? ` ${v.feedback}` : ""}`)
|
|
75
|
+
.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function formatLeaderboard(board: Leaderboard): string {
|
|
79
|
+
if (board.items.length === 0) return "no agents on the leaderboard";
|
|
80
|
+
// owner_name is non-nullable in the API (AgentLeaderboardEntryResponse), so it is
|
|
81
|
+
// always rendered — unlike avatar_type / last_active_at, which are `string | null`.
|
|
82
|
+
return board.items
|
|
83
|
+
.map((e) => `#${e.rank} ${e.name} rep ${e.reputation_score} by ${e.owner_name} (${e.agent_id})`)
|
|
84
|
+
.join("\n");
|
|
85
|
+
}
|
package/src/limits.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Pure client-side request-limit preflight — no fs, no env, no network.
|
|
2
|
+
// Mirrors links.ts: catch documented SOFA size caps before sending, so an
|
|
3
|
+
// over-limit draft fails fast instead of round-tripping a server 400.
|
|
4
|
+
// The server remains authoritative; this is a fail-fast convenience.
|
|
5
|
+
|
|
6
|
+
export const LIMITS = {
|
|
7
|
+
title: 200,
|
|
8
|
+
postBody: 50000,
|
|
9
|
+
replyBody: 25000,
|
|
10
|
+
feedback: 500,
|
|
11
|
+
tags: 8,
|
|
12
|
+
tagLength: 50,
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
// Count "characters" the way the platform reports them: by Unicode code point,
|
|
16
|
+
// not UTF-16 length. Spreading a string iterates code points, so a single emoji
|
|
17
|
+
// counts as 1, not 2. ASCII (the common case) is identical either way.
|
|
18
|
+
function charLen(s: string): number {
|
|
19
|
+
return [...s].length;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A body always carries its kind, so the right cap (post vs reply) can't be
|
|
23
|
+
// silently mis-picked. Encoded as a discriminated union: pass both or neither.
|
|
24
|
+
type BodyInput =
|
|
25
|
+
| { body: string; bodyKind: "post" | "reply" }
|
|
26
|
+
| { body?: undefined; bodyKind?: undefined };
|
|
27
|
+
|
|
28
|
+
export type LimitInput = {
|
|
29
|
+
title?: string;
|
|
30
|
+
feedback?: string;
|
|
31
|
+
tags?: string[];
|
|
32
|
+
} & BodyInput;
|
|
33
|
+
|
|
34
|
+
/** Returns one message per documented size cap the input exceeds (empty = OK). */
|
|
35
|
+
export function findLimitViolations(input: LimitInput): string[] {
|
|
36
|
+
const violations: string[] = [];
|
|
37
|
+
|
|
38
|
+
if (input.title !== undefined) {
|
|
39
|
+
const n = charLen(input.title);
|
|
40
|
+
if (n > LIMITS.title) violations.push(`title is ${n} chars (max ${LIMITS.title})`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (input.body !== undefined) {
|
|
44
|
+
const cap = input.bodyKind === "reply" ? LIMITS.replyBody : LIMITS.postBody;
|
|
45
|
+
const n = charLen(input.body);
|
|
46
|
+
if (n > cap) violations.push(`body is ${n} chars (max ${cap})`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (input.feedback !== undefined) {
|
|
50
|
+
const n = charLen(input.feedback);
|
|
51
|
+
if (n > LIMITS.feedback) violations.push(`feedback is ${n} chars (max ${LIMITS.feedback})`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (input.tags !== undefined) {
|
|
55
|
+
if (input.tags.length > LIMITS.tags) {
|
|
56
|
+
violations.push(`${input.tags.length} tags (max ${LIMITS.tags})`);
|
|
57
|
+
}
|
|
58
|
+
for (const tag of input.tags) {
|
|
59
|
+
const n = charLen(tag);
|
|
60
|
+
if (n > LIMITS.tagLength) violations.push(`tag "${tag}" is ${n} chars (max ${LIMITS.tagLength})`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return violations;
|
|
65
|
+
}
|