feat(api): agent identity + personality (makeup) file endpoints
- POST /nodes/:teamId/identity — set the on-board agent's name (agents.default.identity.name + reload) so it adopts the team's choice. - GET/PUT /nodes/:teamId/personality/:file — proxy the node's /api/personality allowlist API (SOUL/IDENTITY/USER/AGENTS/TOOLS/ HEARTBEAT/MEMORY), so the MAKEUP cards can read + overwrite the agent's makeup files and restart it. Both proxy the board gateway with the node's bearer token. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0156f97b36
commit
0797923933
@@ -362,6 +362,53 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Set the on-board agent's name (agents.default.identity.name) so it adopts the
|
||||||
|
// name the team chose at registration. Best-effort from the client's side.
|
||||||
|
app.post('/nodes/:teamId/identity', async (req, res) => {
|
||||||
|
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
|
||||||
|
const b = req.body ?? {}
|
||||||
|
if (typeof b.name !== 'string' || !b.name.trim()) {
|
||||||
|
return res.status(400).json({ error: 'name is required' })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ok = await nodes.setIdentity(String(req.params.teamId), b.name.trim())
|
||||||
|
if (!ok) return res.status(404).json({ error: 'no node registered for team' })
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'could not set the agent name — is your node online?' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Read/write the agent's makeup ("personality") markdown files — the MAKEUP
|
||||||
|
// slide-out cards edit these. Proxies the node's /api/personality allowlist API.
|
||||||
|
const PERSONALITY_ALLOW = new Set(['SOUL.md', 'IDENTITY.md', 'USER.md', 'AGENTS.md', 'TOOLS.md', 'HEARTBEAT.md', 'MEMORY.md'])
|
||||||
|
app.get('/nodes/:teamId/personality/:file', async (req, res) => {
|
||||||
|
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
|
||||||
|
const file = String(req.params.file)
|
||||||
|
if (!PERSONALITY_ALLOW.has(file)) return res.status(400).json({ error: 'file not editable' })
|
||||||
|
try {
|
||||||
|
const r = await nodes.getPersonality(String(req.params.teamId), file)
|
||||||
|
if (!r) return res.status(404).json({ error: 'no node registered for team' })
|
||||||
|
res.json(r)
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'could not read the file — is your node online?' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
app.put('/nodes/:teamId/personality/:file', async (req, res) => {
|
||||||
|
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
|
||||||
|
const file = String(req.params.file)
|
||||||
|
if (!PERSONALITY_ALLOW.has(file)) return res.status(400).json({ error: 'file not editable' })
|
||||||
|
const b = req.body ?? {}
|
||||||
|
if (typeof b.content !== 'string') return res.status(400).json({ error: 'content is required' })
|
||||||
|
try {
|
||||||
|
const ok = await nodes.putPersonality(String(req.params.teamId), file, b.content)
|
||||||
|
if (!ok) return res.status(404).json({ error: 'no node registered for team' })
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'could not save — is your node online?' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Public liveness for a team's board — the wizard/self-test polls this after
|
// Public liveness for a team's board — the wizard/self-test polls this after
|
||||||
// a claim. Online reflects the bridge's live /health + SSE view.
|
// a claim. Online reflects the bridge's live /health + SSE view.
|
||||||
app.get('/nodes/:teamId/status', (req, res) => {
|
app.get('/nodes/:teamId/status', (req, res) => {
|
||||||
|
|||||||
@@ -222,6 +222,69 @@ export async function configureTelegram(node: NodeRef, token: string): Promise<v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the on-board agent's display name (`agents.default.identity.name`) + reload,
|
||||||
|
* so the agent adopts the name the team chose. Same config-prop + watcher path as
|
||||||
|
* {@link configureTelegram} — the gateway auto-creates the key if absent.
|
||||||
|
*/
|
||||||
|
export async function configureIdentity(node: NodeRef, name: string): Promise<void> {
|
||||||
|
const auth = { authorization: `Bearer ${node.token}` }
|
||||||
|
const res = await fetch(`${node.url}/api/config/prop`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...auth, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: 'agents.default.identity.name', value: name, comment: 'set via APESS onboarding' }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`identity write failed (${res.status})`)
|
||||||
|
try {
|
||||||
|
await fetch(`${node.url}/admin/reload`, { method: 'POST', headers: auth })
|
||||||
|
} catch {
|
||||||
|
/* watcher will apply it */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The agent's editable "personality" (makeup) markdown files — the gateway
|
||||||
|
// allowlist. Used to guard which files the participant can edit from the UI.
|
||||||
|
export const PERSONALITY_FILES = [
|
||||||
|
'SOUL.md',
|
||||||
|
'IDENTITY.md',
|
||||||
|
'USER.md',
|
||||||
|
'AGENTS.md',
|
||||||
|
'TOOLS.md',
|
||||||
|
'HEARTBEAT.md',
|
||||||
|
'MEMORY.md',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** Read one of the agent's makeup files from the node (GET /api/personality/{file}). */
|
||||||
|
export async function readPersonality(node: NodeRef, file: string): Promise<{ content: string; exists: boolean }> {
|
||||||
|
const res = await fetch(`${node.url}/api/personality/${encodeURIComponent(file)}?agent=default`, {
|
||||||
|
headers: { authorization: `Bearer ${node.token}` },
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`personality read failed (${res.status})`)
|
||||||
|
const j = (await res.json()) as { content?: string; exists?: boolean }
|
||||||
|
return { content: j.content ?? '', exists: !!j.exists }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrite one of the agent's makeup files (PUT /api/personality/{file}) and
|
||||||
|
* reload so the agent re-reads it. The agent loads these each session anyway, but
|
||||||
|
* we fire a best-effort reload to apply it right away (the in-container watcher
|
||||||
|
* applies it otherwise). Same auth path as {@link configureTelegram}.
|
||||||
|
*/
|
||||||
|
export async function writePersonality(node: NodeRef, file: string, content: string): Promise<void> {
|
||||||
|
const auth = { authorization: `Bearer ${node.token}` }
|
||||||
|
const res = await fetch(`${node.url}/api/personality/${encodeURIComponent(file)}?agent=default`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...auth, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`personality write failed (${res.status})`)
|
||||||
|
try {
|
||||||
|
await fetch(`${node.url}/admin/reload`, { method: 'POST', headers: auth })
|
||||||
|
} catch {
|
||||||
|
/* watcher will apply it */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubscribeOptions {
|
export interface SubscribeOptions {
|
||||||
/** Aborts the whole reconnect loop when fired. */
|
/** Aborts the whole reconnect loop when fired. */
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
@@ -325,6 +388,14 @@ export interface NodeBridge {
|
|||||||
* starts. Resolves `true` on success, `false` if no node is registered;
|
* starts. Resolves `true` on success, `false` if no node is registered;
|
||||||
* throws if the node rejects the config write or reload. */
|
* throws if the node rejects the config write or reload. */
|
||||||
configureTelegram(teamId: string, token: string): Promise<boolean>
|
configureTelegram(teamId: string, token: string): Promise<boolean>
|
||||||
|
/** Set the on-board agent's name so it adopts it. `true` on success, `false` if
|
||||||
|
* no node is registered; throws if the node rejects the write. */
|
||||||
|
setIdentity(teamId: string, name: string): Promise<boolean>
|
||||||
|
/** Read one of the agent's makeup ("personality") files. `null` if no node. */
|
||||||
|
getPersonality(teamId: string, file: string): Promise<{ content: string; exists: boolean } | null>
|
||||||
|
/** Overwrite one of the agent's makeup files + reload. `false` if no node;
|
||||||
|
* throws if the node rejects the write. */
|
||||||
|
putPersonality(teamId: string, file: string, content: string): Promise<boolean>
|
||||||
/** Stream one team's node activity to a participant. Returns an unsubscribe fn. */
|
/** Stream one team's node activity to a participant. Returns an unsubscribe fn. */
|
||||||
onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void
|
onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void
|
||||||
stopAll(): void
|
stopAll(): void
|
||||||
@@ -338,6 +409,9 @@ export interface NodeBridgeDeps {
|
|||||||
send?: (n: NodeRef, m: string, agent?: string) => Promise<void>
|
send?: (n: NodeRef, m: string, agent?: string) => Promise<void>
|
||||||
sendAndWait?: (n: NodeRef, m: string, agent?: string) => Promise<string>
|
sendAndWait?: (n: NodeRef, m: string, agent?: string) => Promise<string>
|
||||||
setTelegram?: (n: NodeRef, token: string) => Promise<void>
|
setTelegram?: (n: NodeRef, token: string) => Promise<void>
|
||||||
|
setIdentity?: (n: NodeRef, name: string) => Promise<void>
|
||||||
|
readPersonality?: (n: NodeRef, file: string) => Promise<{ content: string; exists: boolean }>
|
||||||
|
writePersonality?: (n: NodeRef, file: string, content: string) => Promise<void>
|
||||||
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void
|
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +427,9 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
|
|||||||
const send = deps.send ?? sendPrompt
|
const send = deps.send ?? sendPrompt
|
||||||
const sendAndWait = deps.sendAndWait ?? promptAndWait
|
const sendAndWait = deps.sendAndWait ?? promptAndWait
|
||||||
const setTelegram = deps.setTelegram ?? configureTelegram
|
const setTelegram = deps.setTelegram ?? configureTelegram
|
||||||
|
const applyIdentity = deps.setIdentity ?? configureIdentity
|
||||||
|
const doReadPersonality = deps.readPersonality ?? readPersonality
|
||||||
|
const doWritePersonality = deps.writePersonality ?? writePersonality
|
||||||
const subscribe = deps.subscribe ?? ((n, on, onStatus) => subscribeNodeEvents(n, on, { onStatus }))
|
const subscribe = deps.subscribe ?? ((n, on, onStatus) => subscribeNodeEvents(n, on, { onStatus }))
|
||||||
const online = new Map<string, boolean>()
|
const online = new Map<string, boolean>()
|
||||||
const stops = new Map<string, () => void>()
|
const stops = new Map<string, () => void>()
|
||||||
@@ -408,6 +485,23 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
|
|||||||
await setTelegram(node, token)
|
await setTelegram(node, token)
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
async setIdentity(teamId, name) {
|
||||||
|
const node = registry.get(teamId)
|
||||||
|
if (!node) return false
|
||||||
|
await applyIdentity(node, name)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async getPersonality(teamId, file) {
|
||||||
|
const node = registry.get(teamId)
|
||||||
|
if (!node) return null
|
||||||
|
return doReadPersonality(node, file)
|
||||||
|
},
|
||||||
|
async putPersonality(teamId, file, content) {
|
||||||
|
const node = registry.get(teamId)
|
||||||
|
if (!node) return false
|
||||||
|
await doWritePersonality(node, file, content)
|
||||||
|
return true
|
||||||
|
},
|
||||||
onTeamActivity(teamId, listener) {
|
onTeamActivity(teamId, listener) {
|
||||||
let set = teamListeners.get(teamId)
|
let set = teamListeners.get(teamId)
|
||||||
if (!set) {
|
if (!set) {
|
||||||
|
|||||||
Reference in New Issue
Block a user