Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.

## Unreleased

### Changed
- Message collection reads are now bounded, redacted SQL/server projections. CLI, MCP, Store, HTTP, blocker, pinned, mention, thread, digest, summary, project-panel, watch-startup, and hook collection paths no longer carry source bodies, raw metadata, or raw attachments across collection boundaries. Incident/security collections use a neutral body marker; full content is available only from the exact message-id path.
- Message/channel reads are pure peeks by default. Read-state and receipt mutations now require explicit `--mark-read` / `mark_read: true`; legacy `verbose` collection flags remain accepted but stay preview-only.
- Collection APIs enforce hard result, response-byte, preview-byte, and statement-timeout caps, and expose the preview-page contract in OpenAPI and the generated SDK.
- The interactive TUI now loads preview pages only. `Tab` enters browse mode, `v` fetches the selected exact message, and `m` acknowledges only that selected id; opening a conversation no longer marks a whole channel or session read.
- Message exports now create capped file artifacts instead of returning an unbounded inline body. Preview detail is the default; full detail requires an explicit reason and principal-bound acknowledgement. The cloud API returns an authenticated download path and never exposes its filesystem path.
- Channel notification reads now share a cursored, byte-capped, timeout-capped page contract across local Store, cloud Store, CLI, MCP, OpenAPI, and generated SDK. Cloud targets are bound to the authenticated API-key principal, and `mark_read` acknowledges returned ids only.
- Local Store collection reads execute in terminable SQLite workers, so `timeout_ms` cancels active work rather than checking elapsed time only after a query has finished.
- Malformed typed message filters (`id`, `reply_to`, `since_id`, dates, booleans, and order) now return `400` instead of silently dropping the filter and widening the read.

### Added
- Hermetic safe-read regressions clear ambient cloud/API/database/dotenv routes, block unexpected network access, and verify redaction, restricted-channel suppression, exact-id disclosure, non-mutating peeks, explicit acknowledgements, and cap failures.
- Export-artifact, notification-page, principal-binding, strict-filter, and worker-cancellation regressions, including proof that a timed-out worker cannot perform a late mutation or retain a SQLite lock.

## [0.5.1] - 2026-07-08

### Fixed
Expand Down
52 changes: 42 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ conversations read --to codex --json
conversations channel create engineering --description "Engineering coordination"
conversations channel send engineering "Build is green"
conversations channel read engineering --json
conversations channel read engineering --from codex --mark-read
conversations channel join engineering
conversations dashboard
conversations storage status
Expand Down Expand Up @@ -57,17 +58,22 @@ ids, previews, and a hint for the next detail step.

```bash
conversations read --to codex # compact previews
conversations read --to codex --verbose # full message bodies
conversations show 123 # one full message
conversations read --to codex --json # full machine-readable records
conversations read --to codex --json # bounded machine-readable preview page
conversations read --to codex --limit 10 --cursor 10
conversations digest engineering --cursor 123 --max-bytes 8192 --json
```

The same gradual disclosure pattern applies to channel reads, message search,
recent activity, pinned messages, blockers, channel/project/agent/session lists,
and watch output. Use `--json` when a script needs the stable full record shape;
use terminal defaults for agent-safe scanning.
recent activity, pinned messages, blockers, mentions, threads, summaries, and
watch startup output. Collection paths are server/store projections: they never
carry full content, raw metadata, or raw attachments across the API/MCP boundary.
`--json` returns the stable preview-page envelope; `--verbose` remains accepted
for compatibility but does not restore collection bodies. Use `show <id>` for
one explicit exact message.

Reads are pure peeks by default. Pass `--mark-read` (and `--from <agent>` when
needed) only when the returned IDs should be acknowledged and receipts updated.

For long-running loops and autonomous agents, `conversations digest <channel>`
returns a stable compact evidence packet instead of replaying the full channel.
Expand Down Expand Up @@ -129,15 +135,27 @@ MCP exposes channel-first tools such as `create_channel`, `list_channels`,
`send_to_channel`, `read_channel`, `join_channel`, `leave_channel`,
`subscribe_channel_notifications`, and `summarize_channel`.

MCP read/list/search tools also default to compact summaries. Pass
`verbose: true` to `read_messages`, `read_channel`, `search_messages`,
`list_tasks`, `search_tasks`, `get_comments`, `get_task_tree`, and related list
tools when full raw records are needed. Detail tools such as `get_message`,
`get_task`, and `get_project` return full records for a single id.
MCP message collection tools (`read_messages`, `read_channel`,
`search_messages`, `get_blockers`, `get_mentions`, thread reads, and pinned
reads) return byte/result/time-capped redacted previews. Their compatibility
`verbose` flags never return source bodies. `get_message` is the explicit exact
full-content path for one id. Reads do not change read state or receipts unless
`mark_read: true` is passed explicitly.
Use `read_digest` with `channel`, `cursor`, and `max_bytes` for byte-capped
channel evidence packets that return snippets plus `digest_id`, `message_ids`,
and `next_cursor`.

`export_messages` creates a capped preview artifact and returns only its path or
authenticated download metadata. It never returns message bodies inline. The
CLI equivalent is `conversations export --max-bytes 65536`; full detail is an
explicit local operator action requiring both `--as <agent>` and
`--authorize-full <reason>`.

Notification inbox reads return `{ notifications, next_cursor, has_more,
byte_length, marked_read, ... }`. Supply `cursor`, `max_bytes`, `preview_bytes`,
and `timeout_ms` when paging. In cloud mode the requested agent must match the
API-key principal, and `mark_read` affects only ids returned in that page.

## HTTP mode

Long-lived Streamable HTTP transport (stateless, bind `127.0.0.1` only):
Expand All @@ -162,6 +180,13 @@ engine in the process. Requests to `/v1/*` are authenticated with
`@hasna/contracts` API keys (scope grammar `conversations:read` /
`conversations:write`).

`GET /v1/messages` and blocker/pinned/thread/mention/summary collection routes
project bounded previews in SQL before serialization. They cap results (100),
response bytes (64 KiB), preview bytes (1 KiB), and query time (5 seconds), with
lower defaults. Incident and security bodies are replaced with a neutral marker
on every collection path. `GET /v1/messages/{id}` is the sole general exact-body
read; `detail=full` collection requests are rejected.

```bash
export HASNA_CONVERSATIONS_STORAGE_MODE=cloud
export HASNA_CONVERSATIONS_DATABASE_URL="postgres://…?sslmode=require&uselibpqcompat=true"
Expand Down Expand Up @@ -197,6 +222,13 @@ const client = new ConversationsClient({
apiKey: process.env.CONVERSATIONS_API_KEY!,
});
await client.sendMessage({ from: "me", to: "you", content: "hi", channel: "deploys" });
const notifications = await client.readChannelNotifications({
limit: 20, cursor: 0, max_bytes: 16_384, timeout_ms: 3_000,
});
const { artifact } = await client.createMessageExport({
detail: "preview", limit: 100, max_bytes: 65_536, timeout_ms: 3_000,
});
// Fetch artifact.download_path with the same authenticated principal when needed.
```

## Channels
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function App() {
// Compute unread counts per channel
const counts: Record<string, number> = {};
for (const m of allMsgsRes) {
if (m.channel && !m.read_at) {
if (m.channel && m.unread) {
counts[m.channel] = (counts[m.channel] || 0) + 1;
}
}
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/components/channel-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function ChannelFeed({ channelName, onBack }: ChannelFeedProps) {
)}
</div>
<div className="text-sm pl-8">
<Markdown>{msg.content}</Markdown>
<Markdown>{msg.preview}</Markdown>
</div>
</article>
))}
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/components/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function ChatPanel({ open, onClose, sessionId, title }: ChatPanelProps) {
<span className="text-xs text-muted-foreground">{msg.created_at.slice(11, 19)}</span>
</div>
<div className="mt-0.5 text-sm">
<Markdown>{msg.content}</Markdown>
<Markdown>{msg.preview}</Markdown>
</div>
</div>
))
Expand Down
4 changes: 2 additions & 2 deletions dashboard/src/components/messages-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,14 @@ export function MessagesTable({ messages, onSelectMessage }: MessagesTableProps)
</TableCell>
<TableCell className="max-w-xs">
<div className="line-clamp-2">
<Markdown>{msg.content}</Markdown>
<Markdown>{msg.preview}</Markdown>
</div>
</TableCell>
<TableCell>
<PriorityBadge priority={msg.priority} />
</TableCell>
<TableCell>
{msg.read_at ? (
{!msg.unread ? (
<Badge
variant="outline"
className="border-green-300 text-green-700 dark:border-green-800 dark:text-green-400"
Expand Down
6 changes: 3 additions & 3 deletions dashboard/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ export interface Message {
from_agent: string;
to_agent: string;
channel: string | null;
content: string;
preview: string;
priority: string;
working_dir: string | null;
repository: string | null;
branch: string | null;
metadata: Record<string, unknown> | null;
has_metadata: boolean;
created_at: string;
read_at: string | null;
unread: boolean;
}

export interface Session {
Expand Down
36 changes: 36 additions & 0 deletions fixtures/todos-incident-projection-v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"schema_version": 1,
"source": "todos",
"event_id": "iev_adf149b3daa8a314dd30b92b188f0024",
"projection_key": "todos:incident:todos.hasna.xyz:v1:11111111-1111-4111-8111-111111111111:v1",
"authority_id": "todos.hasna.xyz:v1",
"incident_id": "11111111-1111-4111-8111-111111111111",
"transition_id": "itr_adf149b3daa8a314dd30b92b188f0024",
"incident_version": 1,
"occurred_at": "2026-07-18T20:01:00.000Z",
"incident": {
"id": "11111111-1111-4111-8111-111111111111",
"title": "Canonical cross-service incident fixture",
"severity": "high",
"status": "investigating",
"owner": "projector-01",
"affected_scopes": [
"service:conversations"
],
"blocked_scopes": [
"agent:projector-01",
"channel:incidents",
"project:wks_8vJJzXTiFo6sxwRkpPqoI"
],
"containment": null,
"next_action": "Project and acknowledge the canonical incident state",
"deadline": null,
"closure_evidence": [],
"supersedes_id": null,
"superseded_by_id": null,
"resolved_at": null,
"version": 1,
"created_at": "2026-07-18T20:01:00.000Z",
"updated_at": "2026-07-18T20:01:00.000Z"
}
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,17 @@
"types": "./dist/index.d.ts",
"scripts": {
"clean": "rm -rf dist bin",
"build": "bun run clean && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external ink --external react --external chalk && bun build ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun build ./src/server/serve-entry.ts --outfile ./bin/serve.js --target bun && bun build ./src/hooks/blocker-hook.ts --outfile ./bin/hook.js --target bun && bun build ./src/index.ts ./src/sdk/index.ts --outdir ./dist --target bun && (tsc --emitDeclarationOnly --declaration --outDir dist || true)",
"build": "bun run clean && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external ink --external react --external chalk && bun build ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun build ./src/server/serve-entry.ts --outfile ./bin/serve.js --target bun && bun build ./src/hooks/blocker-hook.ts --outfile ./bin/hook.js --target bun && bun build ./src/lib/local-read-worker.ts --outfile ./bin/local-read-worker.js --target bun && bun build ./src/index.ts ./src/sdk/index.ts --outdir ./dist --target bun && (tsc --emitDeclarationOnly --declaration --outDir dist || true)",
"build:dashboard": "cd dashboard && bun install && bun run build",
"test": "bun test",
"test:incident-pg": "bun run ./scripts/verify-incident-projection-pg.ts",
"dev": "bun run ./src/cli/index.tsx",
"serve": "bun run ./src/server/serve-entry.ts",
"migrate": "bun run ./src/server/migrate.ts",
"kit:vendor": "bunx @hasna/contracts vendor-kit",
"kit:check": "bunx @hasna/contracts vendor-kit --check",
"sdk:generate": "bun run ./scripts/generate-sdk.ts",
"sdk:check": "bun run ./scripts/generate-sdk.ts --check",
"typecheck": "tsc --noEmit",
"prepublishOnly": "bun run build:dashboard && bun run build",
"postinstall": "mkdir -p $HOME/.hasna/conversations $HOME/.hasna/conversations/training 2>/dev/null || true"
Expand Down
45 changes: 43 additions & 2 deletions scripts/generate-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* the generated file; re-run `bun run sdk:generate` after changing the spec.
*/

import { mkdirSync, writeFileSync } from "fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import { generateSdkFromOpenApi } from "@hasna/contracts/sdk";
Expand All @@ -20,11 +20,52 @@ const result = generateSdkFromOpenApi(openapiSpec as any, {
apiKeyHeader: "x-api-key",
});

// The shared generator intentionally widens single-value enums. For this
// cross-service append-only wire, preserve the schema discriminants as literal
// types so producer drift is rejected at compile time as well as at runtime.
let generatedCode = result.code;
for (const interfaceName of ["IncidentProjectionEventV1", "IncidentProjectionRecord"]) {
const widened = new RegExp(`(export interface ${interfaceName} \\{[^\\n]*?)"schema_version": number; "source": string;`);
const literal = new RegExp(`export interface ${interfaceName} \\{[^\\n]*?"schema_version": 1; "source": "todos";`);
if (widened.test(generatedCode)) {
generatedCode = generatedCode.replace(widened, '$1"schema_version": 1; "source": "todos";');
} else if (!literal.test(generatedCode)) {
throw new Error(`SDK generator output for ${interfaceName} no longer contains the expected discriminants`);
}
}

// The endpoint negotiates two artifact representations. The shared generator
// selects the first OpenAPI media type, while the runtime correctly parses JSON
// arrays and returns CSV as text. Preserve that media-type union in the public
// signature until the shared generator emits response-content unions itself.
const downloadExportArray = /async downloadMessageExport\(([^\n]+)\): Promise<Array<MessagePreview \| Message>>/;
const downloadExportUnion = /async downloadMessageExport\(([^\n]+)\): Promise<Array<MessagePreview \| Message> \| string>/;
if (downloadExportArray.test(generatedCode)) {
generatedCode = generatedCode.replace(
downloadExportArray,
"async downloadMessageExport($1): Promise<Array<MessagePreview | Message> | string>",
);
} else if (!downloadExportUnion.test(generatedCode)) {
throw new Error("SDK generator output for downloadMessageExport no longer contains the expected artifact type");
}

const header =
"// @generated from src/server/openapi.ts by scripts/generate-sdk.ts — DO NOT EDIT.\n" +
"// Regenerate: bun run sdk:generate\n\n";

writeFileSync(join(outDir, "index.ts"), header + result.code);
const outFile = join(outDir, "index.ts");
const generated = header + generatedCode;
if (process.argv.includes("--check")) {
const current = existsSync(outFile) ? readFileSync(outFile, "utf8") : "";
if (current !== generated) {
console.error("generated SDK is stale; run: bun run sdk:generate");
process.exit(1);
}
console.log(`ok generated SDK is current (${result.operations.length} operations)`);
process.exit(0);
}

writeFileSync(outFile, generated);

console.log(`ok generated SDK -> src/sdk/index.ts (${result.operations.length} operations)`);
if (result.warnings.length) {
Expand Down
Loading