stagent 0.3.5 → 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.
Files changed (74) hide show
  1. package/README.md +11 -0
  2. package/dist/cli.js +39 -10
  3. package/drizzle.config.ts +3 -1
  4. package/package.json +3 -1
  5. package/src/app/api/book/bookmarks/route.ts +73 -0
  6. package/src/app/api/book/progress/route.ts +79 -0
  7. package/src/app/api/book/regenerate/route.ts +111 -0
  8. package/src/app/api/book/stage/route.ts +13 -0
  9. package/src/app/api/chat/conversations/[id]/respond/route.ts +19 -20
  10. package/src/app/api/chat/conversations/[id]/route.ts +2 -1
  11. package/src/app/api/documents/[id]/route.ts +34 -2
  12. package/src/app/api/documents/route.ts +91 -0
  13. package/src/app/api/settings/runtime/route.ts +46 -0
  14. package/src/app/book/page.tsx +14 -0
  15. package/src/app/chat/page.tsx +7 -1
  16. package/src/app/globals.css +375 -0
  17. package/src/app/projects/[id]/page.tsx +31 -6
  18. package/src/app/settings/page.tsx +2 -0
  19. package/src/app/{playbook → user-guide}/[slug]/page.tsx +12 -2
  20. package/src/app/{playbook → user-guide}/page.tsx +2 -2
  21. package/src/app/workflows/[id]/page.tsx +28 -2
  22. package/src/components/book/book-reader.tsx +801 -0
  23. package/src/components/book/chapter-generation-bar.tsx +109 -0
  24. package/src/components/book/content-blocks.tsx +432 -0
  25. package/src/components/book/path-progress.tsx +33 -0
  26. package/src/components/book/path-selector.tsx +42 -0
  27. package/src/components/book/try-it-now.tsx +164 -0
  28. package/src/components/chat/chat-activity-indicator.tsx +92 -0
  29. package/src/components/chat/chat-message-list.tsx +3 -0
  30. package/src/components/chat/chat-message.tsx +22 -6
  31. package/src/components/chat/chat-permission-request.tsx +5 -1
  32. package/src/components/chat/chat-question.tsx +3 -0
  33. package/src/components/chat/chat-shell.tsx +130 -19
  34. package/src/components/chat/conversation-list.tsx +8 -2
  35. package/src/components/playbook/adoption-heatmap.tsx +1 -1
  36. package/src/components/playbook/journey-card.tsx +1 -1
  37. package/src/components/playbook/playbook-card.tsx +1 -1
  38. package/src/components/playbook/playbook-detail-view.tsx +15 -5
  39. package/src/components/playbook/playbook-homepage.tsx +1 -1
  40. package/src/components/playbook/playbook-updated-badge.tsx +1 -1
  41. package/src/components/projects/project-detail.tsx +147 -27
  42. package/src/components/projects/project-form-sheet.tsx +6 -2
  43. package/src/components/projects/project-list.tsx +1 -1
  44. package/src/components/settings/runtime-timeout-section.tsx +170 -0
  45. package/src/components/shared/app-sidebar.tsx +7 -1
  46. package/src/components/shared/command-palette.tsx +4 -4
  47. package/src/hooks/use-chapter-generation.ts +255 -0
  48. package/src/lib/agents/claude-agent.ts +12 -6
  49. package/src/lib/agents/runtime/claude.ts +29 -3
  50. package/src/lib/book/chapter-generator.ts +193 -0
  51. package/src/lib/book/chapter-mapping.ts +91 -0
  52. package/src/lib/book/content.ts +251 -0
  53. package/src/lib/book/markdown-parser.ts +317 -0
  54. package/src/lib/book/reading-paths.ts +82 -0
  55. package/src/lib/book/types.ts +152 -0
  56. package/src/lib/book/update-detector.ts +157 -0
  57. package/src/lib/chat/codex-engine.ts +537 -0
  58. package/src/lib/chat/context-builder.ts +18 -4
  59. package/src/lib/chat/engine.ts +116 -39
  60. package/src/lib/chat/model-discovery.ts +13 -5
  61. package/src/lib/chat/permission-bridge.ts +14 -2
  62. package/src/lib/chat/stagent-tools.ts +2 -0
  63. package/src/lib/chat/system-prompt.ts +16 -1
  64. package/src/lib/chat/tools/chat-history-tools.ts +177 -0
  65. package/src/lib/chat/tools/document-tools.ts +204 -0
  66. package/src/lib/chat/tools/settings-tools.ts +30 -3
  67. package/src/lib/chat/types.ts +8 -1
  68. package/src/lib/constants/settings.ts +2 -0
  69. package/src/lib/data/chat.ts +83 -2
  70. package/src/lib/data/clear.ts +8 -0
  71. package/src/lib/db/bootstrap.ts +24 -0
  72. package/src/lib/db/schema.ts +32 -0
  73. package/src/lib/docs/types.ts +9 -0
  74. /package/src/app/api/{playbook → user-guide}/status/route.ts +0 -0
package/README.md CHANGED
@@ -73,6 +73,7 @@ Stagent ships a shared runtime registry that routes tasks, schedules, and workfl
73
73
  | 🧪 | **[E2E Test Automation](#e2e-test-automation)** | API-level end-to-end test suite covering both runtimes, 4 profiles, and 4 workflow patterns |
74
74
  | ⌨️ | **[Command Palette](#command-palette)** | Global `⌘K` search for fast navigation across tasks, projects, workflows, and settings |
75
75
  | 📖 | **[Playbook](#playbook)** | Built-in documentation with usage-stage awareness, adoption heatmap, and guided learning journeys |
76
+ | 📚 | **[Living Book](#living-book)** | AI-native book reader with 9 chapters, agent-powered regeneration, staleness detection, and reading paths |
76
77
 
77
78
  ---
78
79
 
@@ -207,6 +208,16 @@ Built-in documentation system at `/playbook` with usage-stage awareness that ada
207
208
 
208
209
  <img src="https://raw.githubusercontent.com/navam-io/stagent/main/public/readme/playbook-list.png" alt="Stagent playbook documentation" width="1200" />
209
210
 
211
+ #### Living Book
212
+ AI-native book reader at `/book` with 9 chapters across 3 parts (Foundation, Intelligence, Autonomy). Each chapter is generated from Stagent's own source code and feature docs by the document-writer agent — making this a book that writes itself.
213
+
214
+ - **Chapter regeneration** — one-click regeneration via the document-writer agent profile with fire-and-forget task execution
215
+ - **Staleness detection** — git-based change tracking compares source file timestamps against `lastGeneratedBy` frontmatter to show when chapters need updating
216
+ - **Live progress streaming** — SSE subscription shows real-time agent steps during generation (Reading files → Planning → Composing → Writing) with fade-in animation
217
+ - **Reading paths** — 4 persona-based paths (Getting Started, Team Lead, Power User, Developer) filter chapter navigation
218
+ - **Try It Now** — each chapter links to related Playbook feature docs and user journeys
219
+ - **Author's Notes** — collapsible callout blocks with behind-the-scenes commentary
220
+
210
221
  ### Platform
211
222
 
212
223
  #### Tool Permission Persistence
package/dist/cli.js CHANGED
@@ -104,7 +104,9 @@ var STAGENT_TABLES = [
104
104
  "environment_sync_ops",
105
105
  "environment_templates",
106
106
  "conversations",
107
- "chat_messages"
107
+ "chat_messages",
108
+ "reading_progress",
109
+ "bookmarks"
108
110
  ];
109
111
  function bootstrapStagentDatabase(sqlite2) {
110
112
  sqlite2.exec(`
@@ -448,6 +450,26 @@ function bootstrapStagentDatabase(sqlite2) {
448
450
  CREATE INDEX IF NOT EXISTS idx_chat_messages_conversation_id ON chat_messages(conversation_id);
449
451
  CREATE INDEX IF NOT EXISTS idx_chat_messages_conversation_created ON chat_messages(conversation_id, created_at);
450
452
  `);
453
+ sqlite2.exec(`
454
+ CREATE TABLE IF NOT EXISTS reading_progress (
455
+ chapter_id TEXT PRIMARY KEY NOT NULL,
456
+ progress INTEGER DEFAULT 0 NOT NULL,
457
+ scroll_position INTEGER DEFAULT 0 NOT NULL,
458
+ last_read_at INTEGER NOT NULL,
459
+ updated_at INTEGER NOT NULL
460
+ );
461
+
462
+ CREATE TABLE IF NOT EXISTS bookmarks (
463
+ id TEXT PRIMARY KEY NOT NULL,
464
+ chapter_id TEXT NOT NULL,
465
+ section_id TEXT,
466
+ scroll_position INTEGER DEFAULT 0 NOT NULL,
467
+ label TEXT NOT NULL,
468
+ created_at INTEGER NOT NULL
469
+ );
470
+
471
+ CREATE INDEX IF NOT EXISTS idx_bookmarks_chapter_id ON bookmarks(chapter_id);
472
+ `);
451
473
  }
452
474
  function hasLegacyStagentTables(sqlite2) {
453
475
  const placeholders = STAGENT_TABLES.map(() => "?").join(", ");
@@ -496,15 +518,16 @@ function markAllMigrationsApplied(sqlite2, migrationsFolder, migrationsTable = "
496
518
  var __dirname = dirname2(fileURLToPath(import.meta.url));
497
519
  var appDir = join3(__dirname, "..");
498
520
  var launchCwd = process.cwd();
499
- var DATA_DIR = getStagentDataDir();
500
- var dbPath = getStagentDbPath();
501
521
  var pkg = JSON.parse(readFileSync(join3(appDir, "package.json"), "utf-8"));
502
- var HELP_TEXT = `
522
+ function getHelpText() {
523
+ const dir = getStagentDataDir();
524
+ const db2 = getStagentDbPath();
525
+ return `
503
526
  Data:
504
- Directory ${DATA_DIR}
505
- Database ${dbPath}
506
- Sessions ${join3(DATA_DIR, "sessions")}
507
- Logs ${join3(DATA_DIR, "logs")}
527
+ Directory ${dir}
528
+ Database ${db2}
529
+ Sessions ${join3(dir, "sessions")}
530
+ Logs ${join3(dir, "logs")}
508
531
 
509
532
  Environment variables:
510
533
  STAGENT_DATA_DIR Custom data directory for the web app
@@ -513,10 +536,16 @@ Environment variables:
513
536
 
514
537
  Examples:
515
538
  node dist/cli.js --port 3210 --no-open
516
- STAGENT_DATA_DIR=/tmp/stagent node dist/cli.js --reset
539
+ node dist/cli.js --data-dir ~/.stagent-dogfood --port 3100
517
540
  `;
518
- program.name("stagent").description("Governed AI agent workspace").version(pkg.version).addHelpText("after", HELP_TEXT).option("-p, --port <number>", "port to start on", "3000").option("--reset", "delete the local database before starting").option("--no-open", "don't auto-open browser").parse();
541
+ }
542
+ program.name("stagent").description("Governed AI agent workspace").version(pkg.version).addHelpText("after", getHelpText).option("-p, --port <number>", "port to start on", "3000").option("--data-dir <path>", "custom data directory (overrides STAGENT_DATA_DIR)").option("--reset", "delete the local database before starting").option("--no-open", "don't auto-open browser").parse();
519
543
  var opts = program.opts();
544
+ if (opts.dataDir) {
545
+ process.env.STAGENT_DATA_DIR = opts.dataDir;
546
+ }
547
+ var DATA_DIR = getStagentDataDir();
548
+ var dbPath = getStagentDbPath();
520
549
  var requestedPort = Number.parseInt(opts.port, 10);
521
550
  if (Number.isNaN(requestedPort) || requestedPort <= 0) {
522
551
  program.error(`Invalid port: ${opts.port}`);
package/drizzle.config.ts CHANGED
@@ -2,11 +2,13 @@ import { defineConfig } from "drizzle-kit";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
4
 
5
+ const dataDir = process.env.STAGENT_DATA_DIR || join(homedir(), ".stagent");
6
+
5
7
  export default defineConfig({
6
8
  schema: "./src/lib/db/schema.ts",
7
9
  out: "./src/lib/db/migrations",
8
10
  dialect: "sqlite",
9
11
  dbCredentials: {
10
- url: join(homedir(), ".stagent", "stagent.db"),
12
+ url: join(dataDir, "stagent.db"),
11
13
  },
12
14
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stagent",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Governed AI agent workspace for supervised local execution, workflows, documents, and provider runtimes.",
5
5
  "keywords": [
6
6
  "ai",
@@ -50,6 +50,7 @@
50
50
  "test:e2e": "vitest run --config vitest.config.e2e.ts",
51
51
  "test:ui": "vitest --ui",
52
52
  "validate:tokens": "npx tsx design-system/validate-tokens.ts",
53
+ "sync-worktree": "bash bin/sync-worktree.sh",
53
54
  "prepublishOnly": "npm run build:cli"
54
55
  },
55
56
  "engines": {
@@ -88,6 +89,7 @@
88
89
  "remark-gfm": "^4.0.1",
89
90
  "smol-toml": "^1.6.0",
90
91
  "sonner": "^2.0.7",
92
+ "sugar-high": "^1.0.0",
91
93
  "tailwind-merge": "^3",
92
94
  "tailwindcss": "^4",
93
95
  "tw-animate-css": "^1",
@@ -0,0 +1,73 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { bookmarks } from "@/lib/db/schema";
4
+ import { eq } from "drizzle-orm";
5
+ import { randomUUID } from "crypto";
6
+
7
+ /** GET /api/book/bookmarks — return all bookmarks */
8
+ export async function GET() {
9
+ const rows = db.select().from(bookmarks).all();
10
+ return NextResponse.json(
11
+ rows.map((r) => ({
12
+ id: r.id,
13
+ chapterId: r.chapterId,
14
+ sectionId: r.sectionId,
15
+ scrollPosition: r.scrollPosition,
16
+ label: r.label,
17
+ createdAt: r.createdAt.toISOString(),
18
+ }))
19
+ );
20
+ }
21
+
22
+ /** POST /api/book/bookmarks — create a bookmark */
23
+ export async function POST(req: NextRequest) {
24
+ const body = await req.json();
25
+ const { chapterId, sectionId, scrollPosition, label } = body as {
26
+ chapterId: string;
27
+ sectionId?: string;
28
+ scrollPosition: number;
29
+ label: string;
30
+ };
31
+
32
+ if (!chapterId || !label) {
33
+ return NextResponse.json({ error: "chapterId and label required" }, { status: 400 });
34
+ }
35
+
36
+ const id = randomUUID();
37
+ const now = new Date();
38
+
39
+ db.insert(bookmarks)
40
+ .values({
41
+ id,
42
+ chapterId,
43
+ sectionId: sectionId ?? null,
44
+ scrollPosition: scrollPosition ?? 0,
45
+ label,
46
+ createdAt: now,
47
+ })
48
+ .run();
49
+
50
+ return NextResponse.json({
51
+ id,
52
+ chapterId,
53
+ sectionId: sectionId ?? null,
54
+ scrollPosition: scrollPosition ?? 0,
55
+ label,
56
+ createdAt: now.toISOString(),
57
+ }, { status: 201 });
58
+ }
59
+
60
+ /** DELETE /api/book/bookmarks?id=xxx — delete a bookmark */
61
+ export async function DELETE(req: NextRequest) {
62
+ const id = req.nextUrl.searchParams.get("id");
63
+ if (!id) {
64
+ return NextResponse.json({ error: "id required" }, { status: 400 });
65
+ }
66
+
67
+ const result = db.delete(bookmarks).where(eq(bookmarks.id, id)).run();
68
+ if (result.changes === 0) {
69
+ return NextResponse.json({ error: "not found" }, { status: 404 });
70
+ }
71
+
72
+ return NextResponse.json({ ok: true });
73
+ }
@@ -0,0 +1,79 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { readingProgress } from "@/lib/db/schema";
4
+ import { eq } from "drizzle-orm";
5
+
6
+ /** GET /api/book/progress — return all chapter progress */
7
+ export async function GET() {
8
+ const rows = db.select().from(readingProgress).all();
9
+ // Return as a map: chapterId → { progress, scrollPosition, lastReadAt }
10
+ const result: Record<string, { progress: number; scrollPosition: number; lastReadAt: string }> = {};
11
+ for (const row of rows) {
12
+ result[row.chapterId] = {
13
+ // progress is stored as integer 0–1000 for precision in SQLite
14
+ progress: row.progress / 1000,
15
+ scrollPosition: row.scrollPosition,
16
+ lastReadAt: row.lastReadAt.toISOString(),
17
+ };
18
+ }
19
+ return NextResponse.json(result);
20
+ }
21
+
22
+ /** PUT /api/book/progress — upsert progress for a chapter */
23
+ export async function PUT(req: NextRequest) {
24
+ const body = await req.json();
25
+ const { chapterId, progress: pct, scrollPosition } = body as {
26
+ chapterId: string;
27
+ progress: number;
28
+ scrollPosition: number;
29
+ };
30
+
31
+ if (!chapterId || typeof pct !== "number") {
32
+ return NextResponse.json({ error: "chapterId and progress required" }, { status: 400 });
33
+ }
34
+
35
+ const now = new Date();
36
+ // Store progress as integer 0–1000
37
+ const progressInt = Math.round(Math.min(1, Math.max(0, pct)) * 1000);
38
+
39
+ // Check existing to enforce high-water mark
40
+ const existing = db
41
+ .select()
42
+ .from(readingProgress)
43
+ .where(eq(readingProgress.chapterId, chapterId))
44
+ .get();
45
+
46
+ if (existing && existing.progress >= progressInt) {
47
+ // Only update scroll position and timestamp, don't decrease progress
48
+ db.update(readingProgress)
49
+ .set({
50
+ scrollPosition: scrollPosition ?? existing.scrollPosition,
51
+ lastReadAt: now,
52
+ updatedAt: now,
53
+ })
54
+ .where(eq(readingProgress.chapterId, chapterId))
55
+ .run();
56
+ } else if (existing) {
57
+ db.update(readingProgress)
58
+ .set({
59
+ progress: progressInt,
60
+ scrollPosition: scrollPosition ?? 0,
61
+ lastReadAt: now,
62
+ updatedAt: now,
63
+ })
64
+ .where(eq(readingProgress.chapterId, chapterId))
65
+ .run();
66
+ } else {
67
+ db.insert(readingProgress)
68
+ .values({
69
+ chapterId,
70
+ progress: progressInt,
71
+ scrollPosition: scrollPosition ?? 0,
72
+ lastReadAt: now,
73
+ updatedAt: now,
74
+ })
75
+ .run();
76
+ }
77
+
78
+ return NextResponse.json({ ok: true });
79
+ }
@@ -0,0 +1,111 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { tasks } from "@/lib/db/schema";
4
+ import { getChapter } from "@/lib/book/content";
5
+ import { getChapterStaleness, detectStaleChapters } from "@/lib/book/update-detector";
6
+ import { buildChapterRegenerationPrompt } from "@/lib/book/chapter-generator";
7
+ import { executeTaskWithAgent } from "@/lib/agents/router";
8
+
9
+ export const dynamic = "force-dynamic";
10
+
11
+ /**
12
+ * POST /api/book/regenerate
13
+ * Create a task to generate/regenerate a book chapter using the document-writer agent.
14
+ */
15
+ export async function POST(request: Request) {
16
+ try {
17
+ const body = await request.json();
18
+ const { chapterId } = body;
19
+
20
+ if (!chapterId || typeof chapterId !== "string") {
21
+ return NextResponse.json(
22
+ { error: "chapterId is required" },
23
+ { status: 400 }
24
+ );
25
+ }
26
+
27
+ const chapter = getChapter(chapterId);
28
+ if (!chapter) {
29
+ return NextResponse.json(
30
+ { error: `Chapter not found: ${chapterId}` },
31
+ { status: 404 }
32
+ );
33
+ }
34
+
35
+ // Check staleness
36
+ const staleness = getChapterStaleness(chapterId);
37
+
38
+ // Build the regeneration prompt
39
+ const prompt = buildChapterRegenerationPrompt(chapterId);
40
+
41
+ const isNew = chapter.sections.length === 0;
42
+ const verb = isNew ? "Generate" : "Regenerate";
43
+
44
+ // Create a real task with the document-writer profile
45
+ const taskId = crypto.randomUUID();
46
+ const now = new Date();
47
+
48
+ await db.insert(tasks).values({
49
+ id: taskId,
50
+ title: `${verb} Chapter ${chapter.number}: ${chapter.title}`,
51
+ description: prompt,
52
+ agentProfile: "document-writer",
53
+ assignedAgent: "claude-code",
54
+ status: "queued",
55
+ priority: 1,
56
+ createdAt: now,
57
+ updatedAt: now,
58
+ });
59
+
60
+ // Fire-and-forget execution
61
+ executeTaskWithAgent(taskId, "claude-code");
62
+
63
+ return NextResponse.json(
64
+ {
65
+ taskId,
66
+ chapterId,
67
+ chapterTitle: chapter.title,
68
+ chapterNumber: chapter.number,
69
+ isNew,
70
+ staleness,
71
+ },
72
+ { status: 202 }
73
+ );
74
+ } catch (error) {
75
+ console.error("Regenerate error:", error);
76
+ return NextResponse.json(
77
+ { error: "Failed to start chapter generation" },
78
+ { status: 500 }
79
+ );
80
+ }
81
+ }
82
+
83
+ /**
84
+ * GET /api/book/regenerate
85
+ * Check staleness. Supports ?chapterId=X for a single chapter.
86
+ */
87
+ export async function GET(request: NextRequest) {
88
+ try {
89
+ const chapterId = request.nextUrl.searchParams.get("chapterId");
90
+
91
+ if (chapterId) {
92
+ const staleness = getChapterStaleness(chapterId);
93
+ if (!staleness) {
94
+ return NextResponse.json(
95
+ { error: `Chapter not found: ${chapterId}` },
96
+ { status: 404 }
97
+ );
98
+ }
99
+ return NextResponse.json(staleness);
100
+ }
101
+
102
+ const staleness = detectStaleChapters();
103
+ return NextResponse.json({ chapters: staleness });
104
+ } catch (error) {
105
+ console.error("Staleness check error:", error);
106
+ return NextResponse.json(
107
+ { error: "Failed to check staleness" },
108
+ { status: 500 }
109
+ );
110
+ }
111
+ }
@@ -0,0 +1,13 @@
1
+ import { NextResponse } from "next/server";
2
+ import { getUsageStage } from "@/lib/docs/usage-stage";
3
+ import { recommendPath } from "@/lib/book/reading-paths";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export async function GET() {
8
+ const stage = await getUsageStage();
9
+ return NextResponse.json({
10
+ stage,
11
+ recommendedPath: recommendPath(stage),
12
+ });
13
+ }
@@ -40,25 +40,23 @@ export async function POST(
40
40
  );
41
41
  }
42
42
 
43
- if (!hasPendingRequest(requestId)) {
44
- return NextResponse.json(
45
- { error: "No pending request found (may have timed out)" },
46
- { status: 404 }
47
- );
48
- }
49
-
50
- // Resolve the in-memory Promise this unblocks the SDK
51
- const resolved = resolvePendingRequest(requestId, {
52
- behavior,
53
- updatedInput: behavior === "allow" ? updatedInput : undefined,
54
- message: behavior === "deny" ? (message ?? "User denied this action") : undefined,
55
- });
43
+ // Resolve the in-memory Promise if it still exists (unblocks SDK).
44
+ // The request may already be gone (timeout, HMR restart, connection drop)
45
+ // that's fine, we still update DB and UI below.
46
+ const isPending = hasPendingRequest(requestId);
47
+ if (isPending) {
48
+ const resolved = resolvePendingRequest(requestId, {
49
+ behavior,
50
+ updatedInput: behavior === "allow" ? updatedInput : undefined,
51
+ message: behavior === "deny" ? (message ?? "User denied this action") : undefined,
52
+ });
56
53
 
57
- if (!resolved) {
58
- return NextResponse.json(
59
- { error: "Failed to resolve request" },
60
- { status: 500 }
61
- );
54
+ if (!resolved) {
55
+ return NextResponse.json(
56
+ { error: "Failed to resolve request" },
57
+ { status: 500 }
58
+ );
59
+ }
62
60
  }
63
61
 
64
62
  // If "Always Allow" was selected, persist the permission pattern
@@ -71,10 +69,11 @@ export async function POST(
71
69
  }
72
70
  }
73
71
 
74
- // Update the system message status to reflect the response
72
+ // Always update the system message status even for stale requests
73
+ // so the UI reflects the user's action on reload
75
74
  if (messageId) {
76
75
  await updateMessageStatus(messageId, behavior === "allow" ? "complete" : "error");
77
76
  }
78
77
 
79
- return NextResponse.json({ ok: true });
78
+ return NextResponse.json({ ok: true, stale: !isPending });
80
79
  }
@@ -42,7 +42,7 @@ export async function PATCH(
42
42
  ) {
43
43
  const { id } = await params;
44
44
  const body = await req.json();
45
- const { title, status, modelId } = body;
45
+ const { title, status, modelId, runtimeId } = body;
46
46
 
47
47
  const existing = await getConversation(id);
48
48
  if (!existing) {
@@ -64,6 +64,7 @@ export async function PATCH(
64
64
  updates.status = status;
65
65
  }
66
66
  if (modelId !== undefined) updates.modelId = modelId;
67
+ if (runtimeId !== undefined) updates.runtimeId = runtimeId;
67
68
 
68
69
  const updated = await updateConversation(id, updates);
69
70
  return NextResponse.json(updated);
@@ -5,10 +5,14 @@ import { eq } from "drizzle-orm";
5
5
  import { unlink } from "fs/promises";
6
6
  import { z } from "zod/v4";
7
7
 
8
+ import { processDocument } from "@/lib/documents/processor";
9
+
8
10
  const documentPatchSchema = z.object({
9
11
  taskId: z.string().uuid().nullable().optional(),
10
12
  projectId: z.string().uuid().nullable().optional(),
11
13
  category: z.string().max(100).optional(),
14
+ metadata: z.record(z.string(), z.string()).optional(),
15
+ reprocess: z.boolean().optional(),
12
16
  });
13
17
 
14
18
  export async function GET(
@@ -81,13 +85,31 @@ export async function PATCH(
81
85
 
82
86
  if ("taskId" in body) updates.taskId = body.taskId;
83
87
  if ("projectId" in body) updates.projectId = body.projectId;
84
- if ("category" in body) updates.category = body.category;
88
+ if ("category" in body && typeof body.category === "string") updates.category = body.category;
89
+
90
+ // Merge metadata into category field (JSON)
91
+ if (body.metadata) {
92
+ const existing = doc.category ? (() => { try { return JSON.parse(doc.category); } catch { return {}; } })() : {};
93
+ updates.category = JSON.stringify({ ...existing, ...body.metadata });
94
+ }
95
+
96
+ // Reprocess: clear extracted fields and re-run
97
+ if (body.reprocess) {
98
+ updates.extractedText = null;
99
+ updates.processedPath = null;
100
+ updates.processingError = null;
101
+ updates.status = "processing";
102
+ }
85
103
 
86
104
  await db
87
105
  .update(documents)
88
106
  .set(updates)
89
107
  .where(eq(documents.id, id));
90
108
 
109
+ if (body.reprocess) {
110
+ processDocument(id).catch(() => {});
111
+ }
112
+
91
113
  const [updated] = await db
92
114
  .select()
93
115
  .from(documents)
@@ -97,10 +119,12 @@ export async function PATCH(
97
119
  }
98
120
 
99
121
  export async function DELETE(
100
- _req: NextRequest,
122
+ req: NextRequest,
101
123
  { params }: { params: Promise<{ id: string }> }
102
124
  ) {
103
125
  const { id } = await params;
126
+ const url = new URL(req.url);
127
+ const cascadeDelete = url.searchParams.get("cascadeDelete") === "true";
104
128
 
105
129
  const [doc] = await db
106
130
  .select()
@@ -111,6 +135,14 @@ export async function DELETE(
111
135
  return NextResponse.json({ error: "Document not found" }, { status: 404 });
112
136
  }
113
137
 
138
+ // Cascade safety: if linked to a task and cascadeDelete not set, reject
139
+ if (doc.taskId && !cascadeDelete) {
140
+ return NextResponse.json(
141
+ { error: `Document is linked to task ${doc.taskId}. Add ?cascadeDelete=true to confirm.` },
142
+ { status: 409 }
143
+ );
144
+ }
145
+
114
146
  try {
115
147
  await unlink(doc.storagePath);
116
148
  } catch {
@@ -2,6 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { db } from "@/lib/db";
3
3
  import { documents, tasks, projects } from "@/lib/db/schema";
4
4
  import { eq, and, like, or, desc, sql } from "drizzle-orm";
5
+ import { access, stat, copyFile, mkdir } from "fs/promises";
6
+ import { basename, extname, join } from "path";
7
+ import crypto from "crypto";
8
+ import { getStagentUploadsDir } from "@/lib/utils/stagent-paths";
9
+ import { processDocument } from "@/lib/documents/processor";
10
+ import { z } from "zod/v4";
5
11
 
6
12
  const VALID_DOC_STATUSES = ["uploaded", "processing", "ready", "error"] as const;
7
13
  const VALID_DOC_DIRECTIONS = ["input", "output"] as const;
@@ -76,3 +82,88 @@ export async function GET(req: NextRequest) {
76
82
 
77
83
  return NextResponse.json(result);
78
84
  }
85
+
86
+ const MIME_TYPES: Record<string, string> = {
87
+ ".md": "text/markdown",
88
+ ".txt": "text/plain",
89
+ ".json": "application/json",
90
+ ".csv": "text/csv",
91
+ ".html": "text/html",
92
+ ".pdf": "application/pdf",
93
+ ".png": "image/png",
94
+ ".jpg": "image/jpeg",
95
+ ".jpeg": "image/jpeg",
96
+ ".gif": "image/gif",
97
+ ".webp": "image/webp",
98
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
99
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
100
+ };
101
+
102
+ const uploadSchema = z.object({
103
+ file_path: z.string().min(1),
104
+ taskId: z.string().optional(),
105
+ projectId: z.string().optional(),
106
+ direction: z.enum(["input", "output"]).optional().default("output"),
107
+ metadata: z.record(z.string(), z.string()).optional(),
108
+ });
109
+
110
+ export async function POST(req: NextRequest) {
111
+ const raw = await req.json();
112
+ const parsed = uploadSchema.safeParse(raw);
113
+
114
+ if (!parsed.success) {
115
+ return NextResponse.json(
116
+ { error: "Invalid request body", details: parsed.error.issues },
117
+ { status: 400 }
118
+ );
119
+ }
120
+
121
+ const body = parsed.data;
122
+
123
+ try {
124
+ await access(body.file_path);
125
+ } catch {
126
+ return NextResponse.json({ error: `File not found: ${body.file_path}` }, { status: 400 });
127
+ }
128
+
129
+ const stats = await stat(body.file_path);
130
+ if (!stats.isFile()) {
131
+ return NextResponse.json({ error: `Not a file: ${body.file_path}` }, { status: 400 });
132
+ }
133
+
134
+ const originalName = basename(body.file_path);
135
+ const ext = extname(originalName).toLowerCase();
136
+ const mimeType = MIME_TYPES[ext] ?? "application/octet-stream";
137
+ const id = crypto.randomUUID();
138
+ const filename = `${id}${ext}`;
139
+
140
+ const uploadsDir = getStagentUploadsDir();
141
+ await mkdir(uploadsDir, { recursive: true });
142
+ const storagePath = join(uploadsDir, filename);
143
+ await copyFile(body.file_path, storagePath);
144
+
145
+ const now = new Date();
146
+ await db.insert(documents).values({
147
+ id,
148
+ taskId: body.taskId ?? null,
149
+ projectId: body.projectId ?? null,
150
+ filename,
151
+ originalName,
152
+ mimeType,
153
+ size: stats.size,
154
+ storagePath,
155
+ version: 1,
156
+ direction: body.direction,
157
+ status: "uploaded",
158
+ createdAt: now,
159
+ updatedAt: now,
160
+ });
161
+
162
+ // Fire-and-forget preprocessing
163
+ processDocument(id).catch(() => {});
164
+
165
+ return NextResponse.json(
166
+ { documentId: id, status: "uploaded", processingStatus: "queued", originalName, mimeType, size: stats.size },
167
+ { status: 201 }
168
+ );
169
+ }