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.
- package/README.md +11 -0
- package/dist/cli.js +39 -10
- package/drizzle.config.ts +3 -1
- package/package.json +3 -1
- package/src/app/api/book/bookmarks/route.ts +73 -0
- package/src/app/api/book/progress/route.ts +79 -0
- package/src/app/api/book/regenerate/route.ts +111 -0
- package/src/app/api/book/stage/route.ts +13 -0
- package/src/app/api/chat/conversations/[id]/respond/route.ts +19 -20
- package/src/app/api/chat/conversations/[id]/route.ts +2 -1
- package/src/app/api/documents/[id]/route.ts +34 -2
- package/src/app/api/documents/route.ts +91 -0
- package/src/app/api/settings/runtime/route.ts +46 -0
- package/src/app/book/page.tsx +14 -0
- package/src/app/chat/page.tsx +7 -1
- package/src/app/globals.css +375 -0
- package/src/app/projects/[id]/page.tsx +31 -6
- package/src/app/settings/page.tsx +2 -0
- package/src/app/{playbook → user-guide}/[slug]/page.tsx +12 -2
- package/src/app/{playbook → user-guide}/page.tsx +2 -2
- package/src/app/workflows/[id]/page.tsx +28 -2
- package/src/components/book/book-reader.tsx +801 -0
- package/src/components/book/chapter-generation-bar.tsx +109 -0
- package/src/components/book/content-blocks.tsx +432 -0
- package/src/components/book/path-progress.tsx +33 -0
- package/src/components/book/path-selector.tsx +42 -0
- package/src/components/book/try-it-now.tsx +164 -0
- package/src/components/chat/chat-activity-indicator.tsx +92 -0
- package/src/components/chat/chat-message-list.tsx +3 -0
- package/src/components/chat/chat-message.tsx +22 -6
- package/src/components/chat/chat-permission-request.tsx +5 -1
- package/src/components/chat/chat-question.tsx +3 -0
- package/src/components/chat/chat-shell.tsx +130 -19
- package/src/components/chat/conversation-list.tsx +8 -2
- package/src/components/playbook/adoption-heatmap.tsx +1 -1
- package/src/components/playbook/journey-card.tsx +1 -1
- package/src/components/playbook/playbook-card.tsx +1 -1
- package/src/components/playbook/playbook-detail-view.tsx +15 -5
- package/src/components/playbook/playbook-homepage.tsx +1 -1
- package/src/components/playbook/playbook-updated-badge.tsx +1 -1
- package/src/components/projects/project-detail.tsx +147 -27
- package/src/components/projects/project-form-sheet.tsx +6 -2
- package/src/components/projects/project-list.tsx +1 -1
- package/src/components/settings/runtime-timeout-section.tsx +170 -0
- package/src/components/shared/app-sidebar.tsx +7 -1
- package/src/components/shared/command-palette.tsx +4 -4
- package/src/hooks/use-chapter-generation.ts +255 -0
- package/src/lib/agents/claude-agent.ts +12 -6
- package/src/lib/agents/runtime/claude.ts +29 -3
- package/src/lib/book/chapter-generator.ts +193 -0
- package/src/lib/book/chapter-mapping.ts +91 -0
- package/src/lib/book/content.ts +251 -0
- package/src/lib/book/markdown-parser.ts +317 -0
- package/src/lib/book/reading-paths.ts +82 -0
- package/src/lib/book/types.ts +152 -0
- package/src/lib/book/update-detector.ts +157 -0
- package/src/lib/chat/codex-engine.ts +537 -0
- package/src/lib/chat/context-builder.ts +18 -4
- package/src/lib/chat/engine.ts +116 -39
- package/src/lib/chat/model-discovery.ts +13 -5
- package/src/lib/chat/permission-bridge.ts +14 -2
- package/src/lib/chat/stagent-tools.ts +2 -0
- package/src/lib/chat/system-prompt.ts +16 -1
- package/src/lib/chat/tools/chat-history-tools.ts +177 -0
- package/src/lib/chat/tools/document-tools.ts +204 -0
- package/src/lib/chat/tools/settings-tools.ts +30 -3
- package/src/lib/chat/types.ts +8 -1
- package/src/lib/constants/settings.ts +2 -0
- package/src/lib/data/chat.ts +83 -2
- package/src/lib/data/clear.ts +8 -0
- package/src/lib/db/bootstrap.ts +24 -0
- package/src/lib/db/schema.ts +32 -0
- package/src/lib/docs/types.ts +9 -0
- /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
|
-
|
|
522
|
+
function getHelpText() {
|
|
523
|
+
const dir = getStagentDataDir();
|
|
524
|
+
const db2 = getStagentDbPath();
|
|
525
|
+
return `
|
|
503
526
|
Data:
|
|
504
|
-
Directory ${
|
|
505
|
-
Database ${
|
|
506
|
-
Sessions ${join3(
|
|
507
|
-
Logs ${join3(
|
|
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
|
-
|
|
539
|
+
node dist/cli.js --data-dir ~/.stagent-dogfood --port 3100
|
|
517
540
|
`;
|
|
518
|
-
|
|
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(
|
|
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
|
+
"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 (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|