@swarmclawai/swarmclaw 1.5.58 → 1.5.60
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/README.md +16 -18
- package/package.json +1 -1
- package/src/app/api/chats/[id]/turns/[index]/snapshot/route.ts +90 -0
- package/src/app/api/s/[token]/raw/route.ts +79 -0
- package/src/app/api/s/[token]/route.ts +37 -0
- package/src/app/api/share/[id]/route.ts +28 -0
- package/src/app/api/share/route.ts +53 -0
- package/src/app/s/[token]/page.tsx +138 -0
- package/src/cli/index.js +13 -0
- package/src/lib/server/sharing/share-link-repository.test.ts +69 -0
- package/src/lib/server/sharing/share-link-repository.ts +107 -0
- package/src/lib/server/sharing/share-resolver.ts +153 -0
- package/src/lib/server/storage.ts +1 -0
package/README.md
CHANGED
|
@@ -399,6 +399,22 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
399
399
|
|
|
400
400
|
## Releases
|
|
401
401
|
|
|
402
|
+
### v1.5.60 Highlights
|
|
403
|
+
|
|
404
|
+
Adds a turn-snapshot primitive for external replay and comparison tooling, without touching the execution flow.
|
|
405
|
+
|
|
406
|
+
- **Turn snapshot endpoint.** New `GET /api/chats/:id/turns/:index/snapshot` returns the input state of a prior user turn: the message (text + optional imagePath + time), all prior messages in order, the session's effective provider/model/endpoint/credential at snapshot time, and the bound agent's provider/model/systemPrompt when available. Invalid or non-user indices return `400`, out-of-range indices return `404`. CLI: `swarmclaw chats turn-snapshot <chatId> <index>`.
|
|
407
|
+
- **Use case.** External CLIs, notebooks, and comparison harnesses can now capture the exact inputs that produced a given turn and replay them against a different model, provider, or system prompt to compare outputs — without mutating the original session. Pairs with the existing `edit-resend` path (destructive in-session replay) and the new share-link infrastructure in v1.5.59 (share the original turn's context, replay on another instance).
|
|
408
|
+
|
|
409
|
+
### v1.5.59 Highlights
|
|
410
|
+
|
|
411
|
+
Viral-loop release. Adds public share links for missions, skills, and sessions, plus a complementary raw-markdown endpoint so any shared skill installs directly through the existing `POST /api/skills/import`.
|
|
412
|
+
|
|
413
|
+
- **Share links for missions, skills, and sessions.** New `share_links` collection in `src/lib/server/storage.ts` plus `src/lib/server/sharing/share-link-repository.ts`. `POST /api/share { entityType, entityId, expiresInSec?, label? }` mints a cryptographically random 32-char base64url token; `GET /api/share` lists; `GET /api/share/:id` fetches; `DELETE /api/share/:id` revokes (pass `?hard=true` to hard-delete). CLI: `swarmclaw share {list,mint,get,revoke,resolve,raw}`.
|
|
414
|
+
- **Public read endpoints (no auth required).** `GET /api/s/:token` returns the scrubbed JSON payload; `GET /api/s/:token/raw` returns plain markdown (skills return their SKILL.md verbatim, missions render as title + goal + criteria + milestones, sessions as a transcript). Revoked and expired tokens return `404 Not found` without leaking shape information. `GET /s/:token` is a server-rendered page for dropping straight into a browser.
|
|
415
|
+
- **Share-link-based skill install.** `POST /api/skills/import` already accepts an http(s) URL; pointing it at `https://<your-host>/api/s/<token>/raw` now installs a shared skill from another SwarmClaw instance without auth handshakes. Pairs naturally with existing `swarmclaw skills import` CLI.
|
|
416
|
+
- **Share-link repository tests.** `share-link-repository.test.ts` covers mint / list / revoke / lookup-by-token round-trip plus expiry handling against a temporary data dir.
|
|
417
|
+
|
|
402
418
|
### v1.5.58 Highlights
|
|
403
419
|
|
|
404
420
|
This release broadens the built-in evaluation harness so SwarmClaw runs can be benchmarked against named suites, adds two targeted starter kits, exposes live per-session cost data, tightens auto-skill drafting, and ships a zero-setup demo mission template.
|
|
@@ -428,24 +444,6 @@ This release closes the org-orchestration feature gap with Paperclip while keepi
|
|
|
428
444
|
- **Fix: TTS error responses are now proper JSON instead of a raw Buffer blob.** `POST /api/tts` and `POST /api/tts/stream` previously returned `500` with the error message wrapped in a `new NextResponse(string, ...)` that the CLI JSON-decoded into `{"type":"Buffer","data":[78,111,...]}`. Both routes now return `NextResponse.json({error}, {status: 500})`. Regression test added.
|
|
429
445
|
- **Zod-validated PUT/PATCH endpoints — hardening sweep.** Extends the v1.5.55 work (agents, tasks, webhooks) to close the same silent-corruption bug class on the remaining vulnerable routes: `PUT /api/secrets/:id`, `POST /api/secrets`, `PATCH /api/goals/:id`, `PUT /api/providers/:id`, `PUT /api/documents/:id`, `PUT /api/external-agents/:id`, and `PUT /api/chatrooms/:id`. Each route validates against a dedicated schema (`SecretUpdateSchema`, `SecretCreateSchema`, `GoalUpdateSchema`, `ProviderUpdateSchema`, `DocumentUpdateSchema`, `ExternalAgentUpdateSchema`, `ChatroomUpdateSchema`) in `src/lib/validation/schemas.ts`, then filters parsed data to the keys actually present in the raw body so Zod defaults can't overwrite untouched stored fields. Endpoints already doing per-field `typeof` guards (knowledge, gateways, projects) were left as-is.
|
|
430
446
|
|
|
431
|
-
### v1.5.55 Highlights
|
|
432
|
-
|
|
433
|
-
- **Fix: mission budget updates with decimal values no longer silently fail with a 400.** The mission UI's `numOrNull` parsed user input with `Number.parseFloat`, but the API requires `int()` for `maxTokens`, `maxToolCalls`, `maxWallclockSec`, and `maxTurns`. Typing `1000.5` returned a cryptic Zod error to the toast and the update was lost. Added `intOrNull` (rounds) in `mission-edit-sheet.tsx`, `mission-template-install-dialog.tsx`, and `app/missions/page.tsx`. `maxUsd` still accepts decimals.
|
|
434
|
-
- **Fix: mission edit sheet's connectors dropdown was always empty.** The sheet fetched `/connectors` expecting a `Connector[]`, but the endpoint returns `Record<string, Connector>`. The defensive `Array.isArray` fallback quietly rendered an empty list, so users could not attach report connectors when editing a running mission. Now typed as `Record<string, Connector>` and projected with `Object.values`.
|
|
435
|
-
- **Fix: memory search returns results for short (3-4 char) words like `cats`, `blue`, `dog`.** `buildFtsQuery` had a `unique[0].length >= 5` guard that returned an empty FTS query for any single-token search shorter than 5 chars, silently dropping valid searches. The upstream filter already requires ≥3 chars, so the extra guard just excluded useful queries. Removed; regression tests cover `cats`, `blue`, and `dog`.
|
|
436
|
-
- **Fix: `PUT /api/agents/:id` now validates its body with a Zod schema.** Previously the route did `{...current, ...body}` without validation, so sending `{"tools": "not_an_array"}` silently wiped the agent's tool list to `[]`. Added `AgentUpdateSchema = AgentCreateSchema.partial()` and a filter step that keeps only keys present in the raw body (so Zod defaults do not overwrite untouched fields). Bad types now return a 400 with field-level errors. `updateAgent()` keeps a `current.tools` / `current.extensions` fallback as defense-in-depth for internal callers.
|
|
437
|
-
- **Fix: `PUT /api/tasks/:id` now validates its body with a Zod schema.** Same class of bug: a numeric `title` silently corrupted the stored field. Added `TaskUpdateSchema = TaskCreateSchema.partial().extend({...})` with the update-only fields (`appendComment`, `result`, `error`, lifecycle timestamps) and the same raw-key filter pattern. Bad types now 400 with untouched storage.
|
|
438
|
-
- **Fix: `PUT /api/webhooks/:id` now validates its body with a Zod schema.** Previously `{"events": "not_an_array"}` wiped the events list. Added `WebhookUpdateSchema` and explicit `rawKeys.has(...)` guards in the mutate closure so only fields actually present in the body are applied.
|
|
439
|
-
- **Fix: classifier JSON no longer leaks into assistant responses.** Some Ollama / Ollama Cloud turns were emitting the internal `MessageClassification` object directly into the stream (e.g. `{"taskIntent":"research",...}` prepended to the real reply). The existing stripper only matched when `isDeliverableTask` was the first key, so leaks starting with `taskIntent` sailed through to the user. Replaced the regex with a principled detector that brace-matches candidate JSON (string-quote aware) and validates against `MessageClassificationSchema.safeParse` — the schema itself is the source of truth, so future schema changes can't break detection.
|
|
440
|
-
|
|
441
|
-
### v1.5.54 Highlights
|
|
442
|
-
|
|
443
|
-
- **Mission templates library**: the `/missions` page now opens with a curated gallery of starter missions. Each template pre-wires a goal, success criteria, USD / token / turn / wallclock budgets, and a report cadence, so non-technical users can install a working autonomous run in one click. Initial lineup: Daily News Digest, Inbox Triage, Competitor Watch, Weekly Research Report, Social Listener, and Customer Support Triage. Setup notes flag any connector or permission prerequisites before installation. Power-user overrides (budget caps, success criteria, report cadence) live behind a collapsed **Advanced Settings** panel so the default install flow stays one click.
|
|
444
|
-
- **New API routes `GET /api/missions/templates` and `POST /api/missions/templates/:id/instantiate`** with matching CLI commands `swarmclaw missions templates` and `swarmclaw missions instantiate`. Installed missions persist a `templateId` so the origin is traceable for future template-update flows; legacy missions normalize to `templateId: null` on load, no data migration required.
|
|
445
|
-
- **Fix: user-selected provider and model now survive the chat execution pipeline** ([#51](https://github.com/swarmclawai/swarmclaw/pull/51), thanks to [@borislavnnikolov](https://github.com/borislavnnikolov)). Switching provider or model via the inspector panel mid-session was being reverted on every turn because the agent's configured route was unconditionally reapplied in three places. `syncSessionFromAgent` now only syncs credentials / endpoint / fallbacks when the session's provider still matches the route provider, `prepareChatTurn` preserves the user's chosen model after applying the route, and `updateChatSession` auto-resolves a stored credential for the new provider (and clears the stale `apiEndpoint`) when provider changes without an explicit `credentialId`. Restores reliable switching between Copilot CLI, Codex CLI, Groq, and OpenAI-compatible providers.
|
|
446
|
-
|
|
447
|
-
> **Note:** v1.5.53 release notes described the mission templates library, but the feature commit landed after the v1.5.53 tag was cut. v1.5.54 is the release that actually ships it.
|
|
448
|
-
|
|
449
447
|
Older releases: https://swarmclaw.ai/docs/release-notes
|
|
450
448
|
|
|
451
449
|
- GitHub releases: https://github.com/swarmclawai/swarmclaw/releases
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.60",
|
|
4
4
|
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
|
|
5
5
|
"main": "electron-dist/main.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { getSession } from '@/lib/server/sessions/session-repository'
|
|
3
|
+
import { getMessages } from '@/lib/server/messages/message-repository'
|
|
4
|
+
import { loadAgent } from '@/lib/server/agents/agent-repository'
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Turn snapshot — returns the input state of the turn at :index so external
|
|
10
|
+
* tools (CLIs, notebooks, comparison harnesses) can replay the same turn
|
|
11
|
+
* against a different model, provider, or system prompt without mutating
|
|
12
|
+
* the original session.
|
|
13
|
+
*
|
|
14
|
+
* Shape is intentionally minimal and stable:
|
|
15
|
+
* - `userMessage`: the message that opened the turn (text + optional imagePath)
|
|
16
|
+
* - `priorMessages`: everything before that turn, in order
|
|
17
|
+
* - `route`: the session's effective provider/model/endpoint at snapshot time
|
|
18
|
+
* - `agent`: the agent's provider/model/systemPrompt (if bound), for reference
|
|
19
|
+
*/
|
|
20
|
+
export async function GET(
|
|
21
|
+
_req: Request,
|
|
22
|
+
ctx: { params: Promise<{ id: string; index: string }> },
|
|
23
|
+
) {
|
|
24
|
+
const { id, index } = await ctx.params
|
|
25
|
+
const session = getSession(id)
|
|
26
|
+
if (!session) {
|
|
27
|
+
return NextResponse.json({ error: 'session_not_found' }, { status: 404 })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const i = Number.parseInt(index, 10)
|
|
31
|
+
if (!Number.isInteger(i) || i < 0) {
|
|
32
|
+
return NextResponse.json({ error: 'invalid_index' }, { status: 400 })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const messages = getMessages(id)
|
|
36
|
+
if (i >= messages.length) {
|
|
37
|
+
return NextResponse.json({ error: 'index_out_of_range' }, { status: 404 })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const target = messages[i]
|
|
41
|
+
if (!target || target.role !== 'user') {
|
|
42
|
+
return NextResponse.json({ error: 'not_a_user_turn' }, { status: 400 })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const priorMessages = messages.slice(0, i).map((m) => ({
|
|
46
|
+
role: m.role,
|
|
47
|
+
text: m.text || '',
|
|
48
|
+
at: typeof m.time === 'number' ? m.time : null,
|
|
49
|
+
}))
|
|
50
|
+
|
|
51
|
+
const userMessage = {
|
|
52
|
+
text: target.text || '',
|
|
53
|
+
imagePath: target.imagePath || null,
|
|
54
|
+
at: typeof target.time === 'number' ? target.time : null,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const route = {
|
|
58
|
+
provider: session.provider ?? null,
|
|
59
|
+
model: session.model ?? null,
|
|
60
|
+
apiEndpoint: session.apiEndpoint ?? null,
|
|
61
|
+
credentialId: session.credentialId ?? null,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let agent: null | {
|
|
65
|
+
id: string
|
|
66
|
+
provider: string | null
|
|
67
|
+
model: string | null
|
|
68
|
+
systemPrompt: string | null
|
|
69
|
+
} = null
|
|
70
|
+
if (session.agentId) {
|
|
71
|
+
const a = loadAgent(session.agentId)
|
|
72
|
+
if (a) {
|
|
73
|
+
agent = {
|
|
74
|
+
id: a.id,
|
|
75
|
+
provider: (a.provider as string) ?? null,
|
|
76
|
+
model: (a.model as string) ?? null,
|
|
77
|
+
systemPrompt: typeof a.systemPrompt === 'string' ? a.systemPrompt : null,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return NextResponse.json({
|
|
83
|
+
sessionId: id,
|
|
84
|
+
index: i,
|
|
85
|
+
userMessage,
|
|
86
|
+
priorMessages,
|
|
87
|
+
route,
|
|
88
|
+
agent,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isShareLinkActive,
|
|
3
|
+
loadShareLinkByToken,
|
|
4
|
+
} from '@/lib/server/sharing/share-link-repository'
|
|
5
|
+
import { resolveSharedEntity } from '@/lib/server/sharing/share-resolver'
|
|
6
|
+
|
|
7
|
+
export const dynamic = 'force-dynamic'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Public raw-content endpoint for shared entities. Skills return markdown so
|
|
11
|
+
* a second SwarmClaw instance can install via `POST /api/skills/import`
|
|
12
|
+
* without any auth handshake. Missions and sessions return plain-text
|
|
13
|
+
* summaries sized for quick sharing.
|
|
14
|
+
*
|
|
15
|
+
* Returns 404 for missing, expired, or revoked tokens to avoid leaking
|
|
16
|
+
* shape information to a probe.
|
|
17
|
+
*/
|
|
18
|
+
export async function GET(_req: Request, ctx: { params: Promise<{ token: string }> }) {
|
|
19
|
+
const { token } = await ctx.params
|
|
20
|
+
const link = loadShareLinkByToken(token)
|
|
21
|
+
if (!link || !isShareLinkActive(link)) {
|
|
22
|
+
return new Response('Not found', { status: 404 })
|
|
23
|
+
}
|
|
24
|
+
const payload = resolveSharedEntity(link)
|
|
25
|
+
if (!payload) {
|
|
26
|
+
return new Response('Not found', { status: 404 })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (payload.kind === 'skill') {
|
|
30
|
+
return new Response(payload.content, {
|
|
31
|
+
status: 200,
|
|
32
|
+
headers: {
|
|
33
|
+
'content-type': 'text/markdown; charset=utf-8',
|
|
34
|
+
'cache-control': 'public, max-age=60',
|
|
35
|
+
'x-skill-name': encodeURIComponent(payload.name),
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (payload.kind === 'mission') {
|
|
41
|
+
const lines: string[] = []
|
|
42
|
+
lines.push(`# ${payload.title}`, '')
|
|
43
|
+
if (payload.goal) lines.push(payload.goal, '')
|
|
44
|
+
if (payload.successCriteria.length > 0) {
|
|
45
|
+
lines.push('## Success criteria', '')
|
|
46
|
+
for (const c of payload.successCriteria) lines.push(`- ${c}`)
|
|
47
|
+
lines.push('')
|
|
48
|
+
}
|
|
49
|
+
if (payload.milestones.length > 0) {
|
|
50
|
+
lines.push('## Milestones', '')
|
|
51
|
+
for (const m of payload.milestones) {
|
|
52
|
+
lines.push(`- ${new Date(m.at).toISOString().slice(0, 19).replace('T', ' ')}: ${m.note}`)
|
|
53
|
+
}
|
|
54
|
+
lines.push('')
|
|
55
|
+
}
|
|
56
|
+
return new Response(lines.join('\n'), {
|
|
57
|
+
status: 200,
|
|
58
|
+
headers: {
|
|
59
|
+
'content-type': 'text/markdown; charset=utf-8',
|
|
60
|
+
'cache-control': 'public, max-age=60',
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// session
|
|
66
|
+
const lines: string[] = []
|
|
67
|
+
lines.push(`# ${payload.name}`, '')
|
|
68
|
+
if (payload.agentName) lines.push(`Agent: ${payload.agentName}`, '')
|
|
69
|
+
for (const m of payload.messages) {
|
|
70
|
+
lines.push(`### ${m.role}`, '', m.text, '')
|
|
71
|
+
}
|
|
72
|
+
return new Response(lines.join('\n'), {
|
|
73
|
+
status: 200,
|
|
74
|
+
headers: {
|
|
75
|
+
'content-type': 'text/markdown; charset=utf-8',
|
|
76
|
+
'cache-control': 'public, max-age=60',
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import {
|
|
3
|
+
isShareLinkActive,
|
|
4
|
+
loadShareLinkByToken,
|
|
5
|
+
} from '@/lib/server/sharing/share-link-repository'
|
|
6
|
+
import { resolveSharedEntity } from '@/lib/server/sharing/share-resolver'
|
|
7
|
+
|
|
8
|
+
export const dynamic = 'force-dynamic'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Public, unauthenticated fetch of a shared entity by token.
|
|
12
|
+
*
|
|
13
|
+
* Returns the scrubbed payload shape (secrets and credentials are never
|
|
14
|
+
* loaded into the resolver). A 404 is returned for unknown, expired, or
|
|
15
|
+
* revoked tokens to avoid leaking validity to a probe.
|
|
16
|
+
*/
|
|
17
|
+
export async function GET(_req: Request, ctx: { params: Promise<{ token: string }> }) {
|
|
18
|
+
const { token } = await ctx.params
|
|
19
|
+
const link = loadShareLinkByToken(token)
|
|
20
|
+
if (!link || !isShareLinkActive(link)) {
|
|
21
|
+
return NextResponse.json({ error: 'not_found' }, { status: 404 })
|
|
22
|
+
}
|
|
23
|
+
const payload = resolveSharedEntity(link)
|
|
24
|
+
if (!payload) {
|
|
25
|
+
return NextResponse.json({ error: 'entity_missing' }, { status: 404 })
|
|
26
|
+
}
|
|
27
|
+
return NextResponse.json({
|
|
28
|
+
share: {
|
|
29
|
+
id: link.id,
|
|
30
|
+
entityType: link.entityType,
|
|
31
|
+
label: link.label,
|
|
32
|
+
createdAt: link.createdAt,
|
|
33
|
+
expiresAt: link.expiresAt,
|
|
34
|
+
},
|
|
35
|
+
payload,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import {
|
|
3
|
+
loadShareLinkById,
|
|
4
|
+
revokeShareLink,
|
|
5
|
+
deleteShareLink,
|
|
6
|
+
} from '@/lib/server/sharing/share-link-repository'
|
|
7
|
+
|
|
8
|
+
export const dynamic = 'force-dynamic'
|
|
9
|
+
|
|
10
|
+
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
|
11
|
+
const { id } = await ctx.params
|
|
12
|
+
const link = loadShareLinkById(id)
|
|
13
|
+
if (!link) return NextResponse.json({ error: 'not_found' }, { status: 404 })
|
|
14
|
+
return NextResponse.json(link)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function DELETE(req: Request, ctx: { params: Promise<{ id: string }> }) {
|
|
18
|
+
const { id } = await ctx.params
|
|
19
|
+
const { searchParams } = new URL(req.url)
|
|
20
|
+
const hard = searchParams.get('hard') === 'true'
|
|
21
|
+
if (hard) {
|
|
22
|
+
deleteShareLink(id)
|
|
23
|
+
return NextResponse.json({ ok: true, deleted: true })
|
|
24
|
+
}
|
|
25
|
+
const revoked = revokeShareLink(id)
|
|
26
|
+
if (!revoked) return NextResponse.json({ error: 'not_found' }, { status: 404 })
|
|
27
|
+
return NextResponse.json(revoked)
|
|
28
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import {
|
|
4
|
+
createShareLink,
|
|
5
|
+
listShareLinks,
|
|
6
|
+
type ShareEntityType,
|
|
7
|
+
} from '@/lib/server/sharing/share-link-repository'
|
|
8
|
+
import { errorMessage } from '@/lib/shared-utils'
|
|
9
|
+
|
|
10
|
+
export const dynamic = 'force-dynamic'
|
|
11
|
+
|
|
12
|
+
const MintSchema = z.object({
|
|
13
|
+
entityType: z.enum(['mission', 'skill', 'session']),
|
|
14
|
+
entityId: z.string().min(1),
|
|
15
|
+
expiresInSec: z.number().int().positive().nullable().optional(),
|
|
16
|
+
label: z.string().trim().max(120).nullable().optional(),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export async function GET(req: Request) {
|
|
20
|
+
const { searchParams } = new URL(req.url)
|
|
21
|
+
const entityType = searchParams.get('entityType') as ShareEntityType | null
|
|
22
|
+
const entityId = searchParams.get('entityId')
|
|
23
|
+
|
|
24
|
+
let links = listShareLinks()
|
|
25
|
+
if (entityType) links = links.filter((l) => l.entityType === entityType)
|
|
26
|
+
if (entityId) links = links.filter((l) => l.entityId === entityId)
|
|
27
|
+
|
|
28
|
+
// Newest first
|
|
29
|
+
links.sort((a, b) => b.createdAt - a.createdAt)
|
|
30
|
+
return NextResponse.json(links)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function POST(req: Request) {
|
|
34
|
+
try {
|
|
35
|
+
const body: unknown = await req.json()
|
|
36
|
+
const parsed = MintSchema.safeParse(body)
|
|
37
|
+
if (!parsed.success) {
|
|
38
|
+
return NextResponse.json(
|
|
39
|
+
{ error: parsed.error.issues.map((i) => i.message).join(', ') },
|
|
40
|
+
{ status: 400 },
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
const link = createShareLink({
|
|
44
|
+
entityType: parsed.data.entityType,
|
|
45
|
+
entityId: parsed.data.entityId,
|
|
46
|
+
expiresInSec: parsed.data.expiresInSec ?? null,
|
|
47
|
+
label: parsed.data.label ?? null,
|
|
48
|
+
})
|
|
49
|
+
return NextResponse.json(link)
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return NextResponse.json({ error: errorMessage(err) }, { status: 500 })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { notFound } from 'next/navigation'
|
|
2
|
+
import {
|
|
3
|
+
isShareLinkActive,
|
|
4
|
+
loadShareLinkByToken,
|
|
5
|
+
} from '@/lib/server/sharing/share-link-repository'
|
|
6
|
+
import { resolveSharedEntity, type SharedPayload } from '@/lib/server/sharing/share-resolver'
|
|
7
|
+
|
|
8
|
+
export const dynamic = 'force-dynamic'
|
|
9
|
+
|
|
10
|
+
export default async function SharedEntityPage({
|
|
11
|
+
params,
|
|
12
|
+
}: {
|
|
13
|
+
params: Promise<{ token: string }>
|
|
14
|
+
}) {
|
|
15
|
+
const { token } = await params
|
|
16
|
+
const link = loadShareLinkByToken(token)
|
|
17
|
+
if (!link || !isShareLinkActive(link)) notFound()
|
|
18
|
+
const payload = resolveSharedEntity(link)
|
|
19
|
+
if (!payload) notFound()
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="mx-auto max-w-3xl px-6 py-10 font-sans">
|
|
23
|
+
<header className="mb-8">
|
|
24
|
+
<div className="text-xs uppercase tracking-wider text-neutral-500">
|
|
25
|
+
Shared {payload.kind}
|
|
26
|
+
</div>
|
|
27
|
+
{link.label ? (
|
|
28
|
+
<h1 className="mt-1 text-2xl font-semibold">{link.label}</h1>
|
|
29
|
+
) : null}
|
|
30
|
+
</header>
|
|
31
|
+
{renderBody(payload)}
|
|
32
|
+
<footer className="mt-10 border-t border-neutral-200 pt-4 text-xs text-neutral-500">
|
|
33
|
+
Public share link. Secrets and credentials are omitted.
|
|
34
|
+
</footer>
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renderBody(payload: SharedPayload) {
|
|
40
|
+
if (payload.kind === 'mission') {
|
|
41
|
+
return (
|
|
42
|
+
<section>
|
|
43
|
+
<h2 className="text-xl font-semibold">{payload.title}</h2>
|
|
44
|
+
<p className="mt-3 whitespace-pre-wrap text-neutral-800">{payload.goal}</p>
|
|
45
|
+
{payload.successCriteria.length > 0 ? (
|
|
46
|
+
<>
|
|
47
|
+
<h3 className="mt-6 font-semibold">Success criteria</h3>
|
|
48
|
+
<ul className="mt-2 list-disc pl-6 text-neutral-800">
|
|
49
|
+
{payload.successCriteria.map((c, i) => (
|
|
50
|
+
<li key={i}>{c}</li>
|
|
51
|
+
))}
|
|
52
|
+
</ul>
|
|
53
|
+
</>
|
|
54
|
+
) : null}
|
|
55
|
+
{payload.milestones.length > 0 ? (
|
|
56
|
+
<>
|
|
57
|
+
<h3 className="mt-6 font-semibold">Milestones</h3>
|
|
58
|
+
<ol className="mt-2 space-y-1 text-sm text-neutral-800">
|
|
59
|
+
{payload.milestones.map((m, i) => (
|
|
60
|
+
<li key={i}>
|
|
61
|
+
<span className="text-neutral-500">{formatTime(m.at)}:</span> {m.note}
|
|
62
|
+
</li>
|
|
63
|
+
))}
|
|
64
|
+
</ol>
|
|
65
|
+
</>
|
|
66
|
+
) : null}
|
|
67
|
+
{payload.reports.length > 0 ? (
|
|
68
|
+
<>
|
|
69
|
+
<h3 className="mt-6 font-semibold">Reports</h3>
|
|
70
|
+
<div className="mt-3 space-y-4">
|
|
71
|
+
{payload.reports.map((r, i) => (
|
|
72
|
+
<article key={i} className="rounded border border-neutral-200 p-4">
|
|
73
|
+
<div className="mb-2 text-xs text-neutral-500">
|
|
74
|
+
{formatTime(r.at)} · {r.format}
|
|
75
|
+
</div>
|
|
76
|
+
<pre className="whitespace-pre-wrap text-sm text-neutral-800">{r.content}</pre>
|
|
77
|
+
</article>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
</>
|
|
81
|
+
) : null}
|
|
82
|
+
</section>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (payload.kind === 'skill') {
|
|
87
|
+
return (
|
|
88
|
+
<section>
|
|
89
|
+
<h2 className="text-xl font-semibold">{payload.name}</h2>
|
|
90
|
+
{payload.tags.length > 0 ? (
|
|
91
|
+
<div className="mt-2 flex flex-wrap gap-2">
|
|
92
|
+
{payload.tags.map((t) => (
|
|
93
|
+
<span key={t} className="rounded bg-neutral-100 px-2 py-0.5 text-xs text-neutral-700">
|
|
94
|
+
{t}
|
|
95
|
+
</span>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
) : null}
|
|
99
|
+
<p className="mt-4 text-neutral-800">{payload.description}</p>
|
|
100
|
+
<pre className="mt-6 whitespace-pre-wrap rounded border border-neutral-200 bg-neutral-50 p-4 text-sm text-neutral-800">
|
|
101
|
+
{payload.content}
|
|
102
|
+
</pre>
|
|
103
|
+
</section>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<section>
|
|
109
|
+
<h2 className="text-xl font-semibold">{payload.name}</h2>
|
|
110
|
+
{payload.agentName ? (
|
|
111
|
+
<div className="mt-1 text-sm text-neutral-500">Agent: {payload.agentName}</div>
|
|
112
|
+
) : null}
|
|
113
|
+
<div className="mt-6 space-y-4">
|
|
114
|
+
{payload.messages.map((m, i) => (
|
|
115
|
+
<article
|
|
116
|
+
key={i}
|
|
117
|
+
className="rounded border border-neutral-200 p-4"
|
|
118
|
+
>
|
|
119
|
+
<div className="mb-1 flex items-center justify-between text-xs text-neutral-500">
|
|
120
|
+
<span className="uppercase">{m.role}</span>
|
|
121
|
+
{m.at ? <span>{formatTime(m.at)}</span> : null}
|
|
122
|
+
</div>
|
|
123
|
+
<pre className="whitespace-pre-wrap text-sm text-neutral-800">{m.text}</pre>
|
|
124
|
+
</article>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
</section>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatTime(ts: number): string {
|
|
132
|
+
if (!ts) return ''
|
|
133
|
+
try {
|
|
134
|
+
return new Date(ts).toISOString().replace('T', ' ').slice(0, 19)
|
|
135
|
+
} catch {
|
|
136
|
+
return ''
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -579,6 +579,7 @@ const COMMAND_GROUPS = [
|
|
|
579
579
|
cmd('messages-send', 'POST', '/chats/:id/messages', 'Append a user/system message to a chat', { expectsJsonBody: true }),
|
|
580
580
|
cmd('messages-delete', 'DELETE', '/chats/:id/messages', 'Delete a message from a chat', { expectsJsonBody: true }),
|
|
581
581
|
cmd('edit-resend', 'POST', '/chats/:id/edit-resend', 'Edit and resend from a specific message index', { expectsJsonBody: true }),
|
|
582
|
+
cmd('turn-snapshot', 'GET', '/chats/:id/turns/:index/snapshot', 'Snapshot the input state of a prior user turn (for external replay)'),
|
|
582
583
|
cmd('chat', 'POST', '/chats/:id/chat', 'Send chat message (streaming)', {
|
|
583
584
|
expectsJsonBody: true,
|
|
584
585
|
responseType: 'sse',
|
|
@@ -640,6 +641,18 @@ const COMMAND_GROUPS = [
|
|
|
640
641
|
cmd('review-counts', 'GET', '/skill-review-counts', 'Show pending review counts'),
|
|
641
642
|
],
|
|
642
643
|
},
|
|
644
|
+
{
|
|
645
|
+
name: 'share',
|
|
646
|
+
description: 'Public share links for missions, skills, and sessions',
|
|
647
|
+
commands: [
|
|
648
|
+
cmd('list', 'GET', '/share', 'List share links (supports --query entityType=mission,entityId=...)'),
|
|
649
|
+
cmd('mint', 'POST', '/share', 'Mint a new share link', { expectsJsonBody: true }),
|
|
650
|
+
cmd('get', 'GET', '/share/:id', 'Get a share link by id'),
|
|
651
|
+
cmd('revoke', 'DELETE', '/share/:id', 'Revoke a share link'),
|
|
652
|
+
cmd('resolve', 'GET', '/s/:token', 'Resolve a public share token to its scrubbed payload'),
|
|
653
|
+
cmd('raw', 'GET', '/s/:token/raw', 'Fetch the raw markdown body for a share token (skill/mission/session)'),
|
|
654
|
+
],
|
|
655
|
+
},
|
|
643
656
|
{
|
|
644
657
|
name: 'skills',
|
|
645
658
|
description: 'Manage reusable skills',
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
|
|
7
|
+
test('share-link-repository: mint / list / revoke / lookup-by-token round-trip', async () => {
|
|
8
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-share-'))
|
|
9
|
+
process.env.DATA_DIR = tmpDir
|
|
10
|
+
process.env.ACCESS_KEY = 'test-key'
|
|
11
|
+
process.env.CREDENTIAL_SECRET = 'test-secret-32-characters-long!!'
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
createShareLink,
|
|
15
|
+
listShareLinks,
|
|
16
|
+
loadShareLinkById,
|
|
17
|
+
loadShareLinkByToken,
|
|
18
|
+
revokeShareLink,
|
|
19
|
+
isShareLinkActive,
|
|
20
|
+
deleteShareLink,
|
|
21
|
+
} = await import('./share-link-repository')
|
|
22
|
+
|
|
23
|
+
const a = createShareLink({ entityType: 'mission', entityId: 'mission-1', label: 'hello' })
|
|
24
|
+
const b = createShareLink({ entityType: 'skill', entityId: 'skill-2', expiresInSec: 60 })
|
|
25
|
+
|
|
26
|
+
assert.notEqual(a.token, b.token, 'tokens must be unique')
|
|
27
|
+
assert.ok(a.token.length >= 16, 'token should be non-trivially long')
|
|
28
|
+
assert.equal(a.label, 'hello')
|
|
29
|
+
assert.equal(a.revokedAt, null)
|
|
30
|
+
assert.equal(a.expiresAt, null)
|
|
31
|
+
assert.ok(b.expiresAt && b.expiresAt > Date.now(), 'b should have a future expiry')
|
|
32
|
+
|
|
33
|
+
const list = listShareLinks()
|
|
34
|
+
assert.equal(list.length, 2, 'both links should be listed')
|
|
35
|
+
|
|
36
|
+
const byTokenA = loadShareLinkByToken(a.token)
|
|
37
|
+
assert.equal(byTokenA?.id, a.id, 'loadShareLinkByToken finds the link')
|
|
38
|
+
|
|
39
|
+
assert.equal(loadShareLinkByToken('not-a-real-token'), null, 'bad token returns null')
|
|
40
|
+
|
|
41
|
+
// Before revoke — active
|
|
42
|
+
assert.equal(isShareLinkActive(a), true)
|
|
43
|
+
|
|
44
|
+
const revoked = revokeShareLink(a.id)
|
|
45
|
+
assert.ok(revoked?.revokedAt, 'revoke stamps a timestamp')
|
|
46
|
+
assert.equal(isShareLinkActive(revoked!), false, 'revoked link is inactive')
|
|
47
|
+
|
|
48
|
+
// Reload from disk — revocation persisted
|
|
49
|
+
const reloaded = loadShareLinkById(a.id)
|
|
50
|
+
assert.ok(reloaded?.revokedAt, 'revocation persists across reload')
|
|
51
|
+
|
|
52
|
+
// Hard delete
|
|
53
|
+
deleteShareLink(b.id)
|
|
54
|
+
assert.equal(loadShareLinkById(b.id), null, 'hard delete removes the record')
|
|
55
|
+
assert.equal(listShareLinks().length, 1, 'listing drops deleted records')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('share-link-repository: expired links are inactive', async () => {
|
|
59
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-share-'))
|
|
60
|
+
process.env.DATA_DIR = tmpDir
|
|
61
|
+
process.env.ACCESS_KEY = 'test-key'
|
|
62
|
+
process.env.CREDENTIAL_SECRET = 'test-secret-32-characters-long!!'
|
|
63
|
+
|
|
64
|
+
const { createShareLink, isShareLinkActive } = await import('./share-link-repository')
|
|
65
|
+
|
|
66
|
+
const link = createShareLink({ entityType: 'session', entityId: 'sess-1', expiresInSec: 1 })
|
|
67
|
+
assert.equal(isShareLinkActive(link, Date.now()), true, 'fresh link is active')
|
|
68
|
+
assert.equal(isShareLinkActive(link, Date.now() + 2000), false, 'past-expiry is inactive')
|
|
69
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
loadCollection,
|
|
4
|
+
loadStoredItem,
|
|
5
|
+
upsertStoredItem,
|
|
6
|
+
deleteStoredItem,
|
|
7
|
+
} from '@/lib/server/storage'
|
|
8
|
+
import { genId } from '@/lib/id'
|
|
9
|
+
|
|
10
|
+
export type ShareEntityType = 'mission' | 'skill' | 'session'
|
|
11
|
+
|
|
12
|
+
export interface ShareLink {
|
|
13
|
+
id: string
|
|
14
|
+
token: string
|
|
15
|
+
entityType: ShareEntityType
|
|
16
|
+
entityId: string
|
|
17
|
+
label: string | null
|
|
18
|
+
createdAt: number
|
|
19
|
+
expiresAt: number | null
|
|
20
|
+
revokedAt: number | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TOKEN_BYTES = 24 // 32 base64url chars
|
|
24
|
+
|
|
25
|
+
function generateToken(): string {
|
|
26
|
+
return crypto.randomBytes(TOKEN_BYTES).toString('base64url')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function listShareLinks(): ShareLink[] {
|
|
30
|
+
const rows = loadCollection('share_links')
|
|
31
|
+
return Object.values(rows).map((raw) => normalizeShareLink(raw as Record<string, unknown>))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function loadShareLinkById(id: string): ShareLink | null {
|
|
35
|
+
const raw = loadStoredItem('share_links', id)
|
|
36
|
+
return raw ? normalizeShareLink(raw as Record<string, unknown>) : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function loadShareLinkByToken(token: string): ShareLink | null {
|
|
40
|
+
const trimmed = token.trim()
|
|
41
|
+
if (!trimmed) return null
|
|
42
|
+
for (const link of listShareLinks()) {
|
|
43
|
+
if (link.token === trimmed) return link
|
|
44
|
+
}
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CreateShareLinkInput {
|
|
49
|
+
entityType: ShareEntityType
|
|
50
|
+
entityId: string
|
|
51
|
+
expiresInSec?: number | null
|
|
52
|
+
label?: string | null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createShareLink(input: CreateShareLinkInput): ShareLink {
|
|
56
|
+
const now = Date.now()
|
|
57
|
+
const link: ShareLink = {
|
|
58
|
+
id: genId(),
|
|
59
|
+
token: generateToken(),
|
|
60
|
+
entityType: input.entityType,
|
|
61
|
+
entityId: input.entityId,
|
|
62
|
+
label: input.label?.trim() || null,
|
|
63
|
+
createdAt: now,
|
|
64
|
+
expiresAt:
|
|
65
|
+
input.expiresInSec && input.expiresInSec > 0
|
|
66
|
+
? now + input.expiresInSec * 1000
|
|
67
|
+
: null,
|
|
68
|
+
revokedAt: null,
|
|
69
|
+
}
|
|
70
|
+
upsertStoredItem('share_links', link.id, link)
|
|
71
|
+
return link
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function revokeShareLink(id: string): ShareLink | null {
|
|
75
|
+
const link = loadShareLinkById(id)
|
|
76
|
+
if (!link) return null
|
|
77
|
+
if (link.revokedAt) return link
|
|
78
|
+
const next: ShareLink = { ...link, revokedAt: Date.now() }
|
|
79
|
+
upsertStoredItem('share_links', next.id, next)
|
|
80
|
+
return next
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function deleteShareLink(id: string): void {
|
|
84
|
+
deleteStoredItem('share_links', id)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isShareLinkActive(link: ShareLink, now: number = Date.now()): boolean {
|
|
88
|
+
if (link.revokedAt) return false
|
|
89
|
+
if (link.expiresAt !== null && link.expiresAt <= now) return false
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeShareLink(raw: Record<string, unknown>): ShareLink {
|
|
94
|
+
const entityType = raw.entityType
|
|
95
|
+
const safeEntityType: ShareEntityType =
|
|
96
|
+
entityType === 'mission' || entityType === 'skill' || entityType === 'session' ? entityType : 'mission'
|
|
97
|
+
return {
|
|
98
|
+
id: typeof raw.id === 'string' ? raw.id : '',
|
|
99
|
+
token: typeof raw.token === 'string' ? raw.token : '',
|
|
100
|
+
entityType: safeEntityType,
|
|
101
|
+
entityId: typeof raw.entityId === 'string' ? raw.entityId : '',
|
|
102
|
+
label: typeof raw.label === 'string' ? raw.label : null,
|
|
103
|
+
createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : 0,
|
|
104
|
+
expiresAt: typeof raw.expiresAt === 'number' ? raw.expiresAt : null,
|
|
105
|
+
revokedAt: typeof raw.revokedAt === 'number' ? raw.revokedAt : null,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { ShareEntityType, ShareLink } from './share-link-repository'
|
|
2
|
+
import { loadStoredItem } from '@/lib/server/storage'
|
|
3
|
+
import { listMissionReports } from '@/lib/server/missions/mission-repository'
|
|
4
|
+
|
|
5
|
+
export interface SharedMissionPayload {
|
|
6
|
+
kind: 'mission'
|
|
7
|
+
id: string
|
|
8
|
+
title: string
|
|
9
|
+
goal: string
|
|
10
|
+
successCriteria: string[]
|
|
11
|
+
status: string
|
|
12
|
+
createdAt: number
|
|
13
|
+
milestones: Array<{ at: number; note: string; kind: string }>
|
|
14
|
+
reports: Array<{ at: number; format: string; content: string }>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SharedSkillPayload {
|
|
18
|
+
kind: 'skill'
|
|
19
|
+
id: string
|
|
20
|
+
name: string
|
|
21
|
+
description: string
|
|
22
|
+
tags: string[]
|
|
23
|
+
content: string
|
|
24
|
+
sourceFormat: string | null
|
|
25
|
+
createdAt: number | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SharedSessionPayload {
|
|
29
|
+
kind: 'session'
|
|
30
|
+
id: string
|
|
31
|
+
name: string
|
|
32
|
+
agentName: string | null
|
|
33
|
+
messages: Array<{ role: string; text: string; at: number | null }>
|
|
34
|
+
createdAt: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type SharedPayload = SharedMissionPayload | SharedSkillPayload | SharedSessionPayload
|
|
38
|
+
|
|
39
|
+
const MAX_MESSAGES = 60
|
|
40
|
+
const MAX_MILESTONES = 40
|
|
41
|
+
const MAX_REPORTS = 10
|
|
42
|
+
|
|
43
|
+
export function resolveSharedEntity(link: ShareLink): SharedPayload | null {
|
|
44
|
+
switch (link.entityType) {
|
|
45
|
+
case 'mission':
|
|
46
|
+
return resolveMission(link.entityId)
|
|
47
|
+
case 'skill':
|
|
48
|
+
return resolveSkill(link.entityId)
|
|
49
|
+
case 'session':
|
|
50
|
+
return resolveSession(link.entityId)
|
|
51
|
+
default:
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveMission(id: string): SharedMissionPayload | null {
|
|
57
|
+
const raw = loadStoredItem('agent_missions', id) as Record<string, unknown> | null
|
|
58
|
+
if (!raw) return null
|
|
59
|
+
const milestonesRaw = Array.isArray(raw.milestones) ? raw.milestones : []
|
|
60
|
+
const milestones = milestonesRaw
|
|
61
|
+
.slice(-MAX_MILESTONES)
|
|
62
|
+
.map((m) => {
|
|
63
|
+
const entry = (m || {}) as Record<string, unknown>
|
|
64
|
+
return {
|
|
65
|
+
at: typeof entry.at === 'number' ? entry.at : 0,
|
|
66
|
+
note: typeof entry.note === 'string' ? entry.note : '',
|
|
67
|
+
kind: typeof entry.kind === 'string' ? entry.kind : 'note',
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
let reports: SharedMissionPayload['reports'] = []
|
|
72
|
+
try {
|
|
73
|
+
const rows = listMissionReports(id, MAX_REPORTS)
|
|
74
|
+
reports = rows.map((r) => ({
|
|
75
|
+
at: r.generatedAt,
|
|
76
|
+
format: String(r.format),
|
|
77
|
+
content: r.body,
|
|
78
|
+
}))
|
|
79
|
+
} catch {
|
|
80
|
+
reports = []
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
kind: 'mission',
|
|
85
|
+
id,
|
|
86
|
+
title: typeof raw.title === 'string' ? raw.title : 'Untitled Mission',
|
|
87
|
+
goal: typeof raw.goal === 'string' ? raw.goal : '',
|
|
88
|
+
successCriteria: Array.isArray(raw.successCriteria)
|
|
89
|
+
? (raw.successCriteria as unknown[]).filter((x): x is string => typeof x === 'string')
|
|
90
|
+
: [],
|
|
91
|
+
status: typeof raw.status === 'string' ? raw.status : 'unknown',
|
|
92
|
+
createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : 0,
|
|
93
|
+
milestones,
|
|
94
|
+
reports,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveSkill(id: string): SharedSkillPayload | null {
|
|
99
|
+
const raw = loadStoredItem('skills', id) as Record<string, unknown> | null
|
|
100
|
+
if (!raw) return null
|
|
101
|
+
return {
|
|
102
|
+
kind: 'skill',
|
|
103
|
+
id,
|
|
104
|
+
name: typeof raw.name === 'string' ? raw.name : 'Unnamed Skill',
|
|
105
|
+
description: typeof raw.description === 'string' ? raw.description : '',
|
|
106
|
+
tags: Array.isArray(raw.tags)
|
|
107
|
+
? (raw.tags as unknown[]).filter((x): x is string => typeof x === 'string')
|
|
108
|
+
: [],
|
|
109
|
+
content: typeof raw.content === 'string' ? raw.content : '',
|
|
110
|
+
sourceFormat: typeof raw.sourceFormat === 'string' ? raw.sourceFormat : null,
|
|
111
|
+
createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : null,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resolveSession(id: string): SharedSessionPayload | null {
|
|
116
|
+
const raw = loadStoredItem('sessions', id) as Record<string, unknown> | null
|
|
117
|
+
if (!raw) return null
|
|
118
|
+
const messagesRaw = Array.isArray(raw.messages) ? raw.messages : []
|
|
119
|
+
const messages = messagesRaw.slice(-MAX_MESSAGES).map((m) => {
|
|
120
|
+
const entry = (m || {}) as Record<string, unknown>
|
|
121
|
+
return {
|
|
122
|
+
role: typeof entry.role === 'string' ? entry.role : 'unknown',
|
|
123
|
+
text: typeof entry.content === 'string' ? entry.content : '',
|
|
124
|
+
at: typeof entry.at === 'number' ? entry.at : null,
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
let agentName: string | null = null
|
|
129
|
+
const agentId = typeof raw.agentId === 'string' ? raw.agentId : null
|
|
130
|
+
if (agentId) {
|
|
131
|
+
const agent = loadStoredItem('agents', agentId) as Record<string, unknown> | null
|
|
132
|
+
if (agent && typeof agent.name === 'string') agentName = agent.name
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
kind: 'session',
|
|
137
|
+
id,
|
|
138
|
+
name: typeof raw.name === 'string' ? raw.name : 'Untitled Session',
|
|
139
|
+
agentName,
|
|
140
|
+
messages,
|
|
141
|
+
createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : 0,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Shape enforced on every outbound shared payload: fields that should never
|
|
147
|
+
* leak off-instance. Reasons kept on the function to keep the allowlist obvious.
|
|
148
|
+
*/
|
|
149
|
+
export const SHARE_ALLOWED_ENTITY_TYPES: readonly ShareEntityType[] = [
|
|
150
|
+
'mission',
|
|
151
|
+
'skill',
|
|
152
|
+
'session',
|
|
153
|
+
] as const
|