Sign In

@neeter/server

Package Overview
Dependencies
Maintainers
1
Versions
34
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@neeter/server - npm Package Compare versions

Comparing version
0.16.1
to
0.17.0
+2
-2
dist/index.d.ts

@@ -1,2 +0,2 @@

export type { CustomEvent, SSEEvent } from "@neeter/types";
export type { Base64ImageSource, ContentBlock, CustomEvent, ImageBlock, SSEEvent, TextBlock, UserMessageContent, } from "@neeter/types";
export { createSandboxHook } from "./hooks.js";

@@ -7,3 +7,3 @@ export { createJsonSessionStore } from "./json-store.js";

export { createAgentRouter } from "./router.js";
export { type ResumeOptions, type Session, type SessionInit, SessionManager, type SessionManagerOptions, sessionMeta, } from "./session.js";
export { extractText, type ResumeOptions, type Session, type SessionInit, SessionManager, type SessionManagerOptions, sessionMeta, } from "./session.js";
export { MessageTranslator, sseEncode, streamSession, type TranslatorConfig, } from "./translator.js";

@@ -6,3 +6,3 @@ export { createSandboxHook } from "./hooks.js";

export { createAgentRouter } from "./router.js";
export { SessionManager, sessionMeta, } from "./session.js";
export { extractText, SessionManager, sessionMeta, } from "./session.js";
export { MessageTranslator, sseEncode, streamSession, } from "./translator.js";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { sessionMeta } from "./session.js";
import { extractText, sessionMeta } from "./session.js";
import { sseEncode, streamSession } from "./translator.js";
const VALID_IMAGE_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
function validateContentBlocks(blocks) {
if (blocks.length === 0)
return { ok: false, error: "Content array must not be empty" };
const validated = [];
for (const block of blocks) {
const b = block;
switch (b.type) {
case "text": {
if (typeof b.text !== "string" || !b.text.trim()) {
return { ok: false, error: "Text blocks must have non-empty text" };
}
validated.push({ type: "text", text: b.text });
break;
}
case "image": {
const src = b.source;
if (!src || src.type !== "base64") {
return { ok: false, error: "Image blocks must have a base64 source" };
}
if (!VALID_IMAGE_MEDIA_TYPES.has(src.media_type)) {
return { ok: false, error: `Unsupported media type: ${src.media_type}` };
}
if (typeof src.data !== "string" || !src.data) {
return { ok: false, error: "Image blocks must have non-empty base64 data" };
}
validated.push({
type: "image",
source: {
type: "base64",
media_type: src.media_type,
data: src.data,
},
});
break;
}
default:
return { ok: false, error: `Unknown content block type: ${b.type}` };
}
}
return { ok: true, content: validated };
}
const PERSISTED_EVENTS = new Set([

@@ -101,6 +143,20 @@ "text_delta",

const body = await c.req.json();
if (!body.text?.trim())
return c.json({ error: "Message text required" }, 400);
const text = body.text.trim();
session.pushMessage(text);
let messageContent;
if (Array.isArray(body.content)) {
const result = validateContentBlocks(body.content);
if (!result.ok)
return c.json({ error: result.error }, 400);
messageContent = result.content;
}
else if (body.text?.trim()) {
messageContent = body.text.trim();
}
else {
return c.json({ error: "Message text or content required" }, 400);
}
session.pushMessage(messageContent);
const text = extractText(messageContent);
const persistData = { text };
if (typeof messageContent !== "string")
persistData.content = messageContent;
const store = sessions.getStore();

@@ -110,3 +166,3 @@ if (store && session.sdkSessionId) {

meta: sessionMeta(session),
events: [{ event: "user_message", data: JSON.stringify({ text }) }],
events: [{ event: "user_message", data: JSON.stringify(persistData) }],
});

@@ -116,3 +172,3 @@ }

const pending = pendingUserMessages.get(session) ?? [];
pending.push(text);
pending.push(messageContent);
pendingUserMessages.set(session, pending);

@@ -134,6 +190,9 @@ }

if (pending) {
const userEvents = pending.map((text) => ({
event: "user_message",
data: JSON.stringify({ text }),
}));
const userEvents = pending.map((content) => {
const text = extractText(content);
const data = { text };
if (typeof content !== "string")
data.content = content;
return { event: "user_message", data: JSON.stringify(data) };
});
void store.save(session.sdkSessionId, {

@@ -140,0 +199,0 @@ meta: sessionMeta(session),

import { type HookCallbackMatcher, type HookEvent, type McpServerConfig, type Query } from "@anthropic-ai/claude-agent-sdk";
import type { SessionHistoryEntry, SessionStore, SSEEvent } from "@neeter/types";
import type { SessionHistoryEntry, SessionStore, SSEEvent, UserMessageContent } from "@neeter/types";
import { PermissionGate } from "./permission-gate.js";
/** Extract the text portion from a `UserMessageContent` value. */
export declare function extractText(content: UserMessageContent): string;
/** Configuration returned by the `SessionManager` factory for each new session. */

@@ -60,3 +62,3 @@ export interface SessionInit<TCtx> {

context: TCtx;
pushMessage(text: string): void;
pushMessage(content: UserMessageContent): void;
messageIterator: Query;

@@ -63,0 +65,0 @@ permissionGate: PermissionGate;

@@ -13,2 +13,11 @@ import { randomUUID } from "node:crypto";

}
/** Extract the text portion from a `UserMessageContent` value. */
export function extractText(content) {
if (typeof content === "string")
return content;
return content
.filter((b) => b.type === "text")
.map((b) => b.text)
.join(" ");
}
export function sessionMeta(session) {

@@ -184,8 +193,8 @@ return {

replayGate: !!extraQueryOptions?.resume,
pushMessage: (text) => {
pushMessage: (content) => {
session.replayGate = false;
session.lastActivityAt = Date.now();
if (!session.firstPrompt)
session.firstPrompt = text;
channel.push(userMessage(text));
session.firstPrompt = extractText(content);
channel.push(userMessage(content));
},

@@ -192,0 +201,0 @@ messageIterator,

@@ -157,5 +157,7 @@ import { PushChannel } from "./push-channel.js";

const msg = message.message;
// Only emit checkpoints for real user messages (string content),
// not tool-result round-trips (array content with tool_result blocks).
const isToolResult = Array.isArray(msg?.content);
// Emit checkpoints for real user messages (string or content-array with
// text/image blocks), not tool-result round-trips (content-array with
// tool_result blocks).
const isToolResult = Array.isArray(msg?.content) &&
msg.content.some((b) => b.type === "tool_result");
const uuid = message.uuid;

@@ -162,0 +164,0 @@ if (uuid && !isToolResult) {

{
"name": "@neeter/server",
"version": "0.16.1",
"version": "0.17.0",
"description": "Hono server toolkit for building chat UIs on top of the Claude Agent SDK",

@@ -24,3 +24,3 @@ "license": "MIT",

"dependencies": {
"@neeter/types": "0.16.1"
"@neeter/types": "0.17.0"
},

@@ -27,0 +27,0 @@ "peerDependencies": {

@@ -48,2 +48,25 @@ # @neeter/server

## Multimodal messages
Send images alongside text via the messages endpoint:
```typescript
// POST /api/sessions/:id/messages
{
"content": [
{ "type": "text", "text": "Describe this image" },
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "<base64>"
}
}
]
}
```
Plain `{ "text": "..." }` payloads continue to work as before. See the [Server Guide](https://github.com/quantumleeps/neeter/blob/main/docs/server.md#multimodal-messages) for details.
## Examples

@@ -50,0 +73,0 @@