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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ dist/
.DS_Store
coverage/
.turbo/
AGENTS.md
116 changes: 115 additions & 1 deletion packages/cli/src/__tests__/cli-integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { mkdtemp, readFile, rm, stat, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -190,4 +190,118 @@ describe("CLI integration", () => {
expect(exitCode).not.toBe(0);
});
});

describe("inkos book file", () => {
const bookId = "test-book";

beforeAll(async () => {
const bookDir = join(projectDir, "books", bookId);
const storyDir = join(bookDir, "story");
await mkdir(storyDir, { recursive: true });
await writeFile(
join(bookDir, "book.json"),
JSON.stringify({
id: bookId,
title: "Test Book",
platform: "tomato",
genre: "xuanhuan",
status: "active",
targetChapters: 100,
chapterWordCount: 3000,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
}, null, 2),
"utf-8",
);
await writeFile(join(storyDir, "story_bible.md"), "# Original Bible", "utf-8");
await writeFile(
join(storyDir, "book_rules.md"),
[
"---",
'version: "1.0"',
"protagonist:",
" name: 林烬",
" personalityLock: [强势冷静]",
" behavioralConstraints: [不圣母]",
"genreLock:",
" primary: xuanhuan",
" forbidden: [都市文风]",
"prohibitions:",
" - 主角关键时刻心软",
"chapterTypesOverride: []",
"fatigueWordsOverride: []",
"additionalAuditDimensions: []",
"enableFullCastTracking: false",
"---",
"",
"## 叙事视角",
"第三人称近距离",
].join("\n"),
"utf-8",
);
});

it("lists editable story files", () => {
const output = run(["book", "file", "list", "--json"]);
const data = JSON.parse(output);
expect(data.files).toContain("story_bible");
expect(data.files).toContain("book_rules");
});

it("shows a story file", () => {
const output = run(["book", "file", "show", bookId, "story_bible"]);
expect(output).toContain("Original Bible");
});

it("updates a story file", async () => {
const output = run([
"book",
"file",
"set",
bookId,
"story_bible",
"--content",
"# Updated Bible",
]);
expect(output).toContain(`Updated story_bible for ${bookId}.`);

const content = await readFile(
join(projectDir, "books", bookId, "story", "story_bible.md"),
"utf-8",
);
expect(content).toBe("# Updated Bible");
});
});

describe("inkos agent session utilities", () => {
beforeAll(async () => {
const sessionsDir = join(projectDir, ".inkos", "agent-sessions");
await mkdir(sessionsDir, { recursive: true });
await writeFile(
join(sessionsDir, "default.json"),
JSON.stringify([
{ role: "user", content: "上一轮说了什么?" },
{ role: "assistant", content: "上一轮我们确认了主角设定。" },
], null, 2),
"utf-8",
);
});

it("shows saved agent history", () => {
const output = run(["agent", "history"]);
expect(output).toContain("Session: default");
expect(output).toContain("上一轮我们确认了主角设定");
});

it("lists saved agent sessions", () => {
const output = run(["agent", "sessions"]);
expect(output).toContain("default");
expect(output).toContain("messages: 2");
});

it("clears a saved agent session", () => {
const output = run(["agent", "clear"]);
expect(output).toContain('Cleared session "default".');
});
});
});
122 changes: 117 additions & 5 deletions packages/cli/src/commands/agent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Command } from "commander";
import { runAgentLoop } from "@actalk/inkos-core";
import { runAgentLoop, StateManager, type ToolCall } from "@actalk/inkos-core";
import { loadConfig, createClient, findProjectRoot, resolveContext, log, logError } from "../utils.js";

export const agentCommand = new Command("agent")
Expand All @@ -8,21 +8,33 @@ export const agentCommand = new Command("agent")
.option("--context <text>", "Additional context (natural language)")
.option("--context-file <path>", "Read additional context from file")
.option("--max-turns <n>", "Maximum agent turns", "20")
.option("--session <id>", "Conversation session ID", "default")
.option("--no-memory", "Disable loading/saving conversation history")
.option("--json", "Output JSON (suppress progress messages)")
.option("--quiet", "Suppress tool call logs")
.action(async (instruction: string, opts) => {
try {
const config = await loadConfig();
const client = createClient(config);
const root = findProjectRoot();
const state = new StateManager(root);
const context = await resolveContext(opts);
const sessionId = opts.session as string;
const useMemory = opts.memory as boolean;
const history = useMemory
? await state.loadAgentSession(sessionId)
: [];

const fullInstruction = context
? `${instruction}\n\n补充信息:${context}`
: instruction;

const maxTurns = parseInt(opts.maxTurns, 10);

if (!opts.json && useMemory) {
log(`[session] ${sessionId} | resumed messages: ${history.length}`);
}

const result = await runAgentLoop(
{
client,
Expand All @@ -32,27 +44,33 @@ export const agentCommand = new Command("agent")
fullInstruction,
{
maxTurns,
sessionId,
useMemory,
onToolCall: opts.quiet || opts.json
? undefined
: (name, args) => {
: (name: string, args: Record<string, unknown>) => {
log(` [tool] ${name}(${JSON.stringify(args)})`);
},
onToolResult: opts.quiet || opts.json
? undefined
: (name, result) => {
: (name: string, result: string) => {
const preview = result.length > 200 ? `${result.slice(0, 200)}...` : result;
log(` [result] ${name} → ${preview}`);
},
onMessage: opts.json
? undefined
: (content) => {
: (content: string) => {
log(`\n${content}`);
},
},
);

if (opts.json) {
log(JSON.stringify({ result }));
log(JSON.stringify({
result,
sessionId: useMemory ? sessionId : null,
resumedMessages: history.length,
}));
}
} catch (e) {
if (opts.json) {
Expand All @@ -63,3 +81,97 @@ export const agentCommand = new Command("agent")
process.exit(1);
}
});

agentCommand
.command("history")
.description("Show saved conversation history")
.argument("[session-id]", "Session ID", "default")
.option("--json", "Output JSON")
.action(async (sessionId: string, opts) => {
try {
const state = new StateManager(findProjectRoot());
const messages = await state.loadAgentSession(sessionId);

if (opts.json) {
log(JSON.stringify({ sessionId, messages }, null, 2));
return;
}

if (messages.length === 0) {
log(`No saved history for session \"${sessionId}\".`);
return;
}

log(`Session: ${sessionId}`);
for (const message of messages) {
if (message.role === "user") {
log(`\n[user]\n${message.content}`);
continue;
}
if (message.role === "assistant") {
log(`\n[assistant]\n${message.content ?? ""}`);
if (message.toolCalls?.length) {
log(`[tool-calls] ${message.toolCalls.map((tool: ToolCall) => tool.name).join(", ")}`);
}
continue;
}
if (message.role === "tool") {
log(`\n[tool:${message.toolCallId}]\n${message.content}`);
}
}
} catch (e) {
if (opts.json) {
log(JSON.stringify({ error: String(e) }));
} else {
logError(`Failed to read agent history: ${e}`);
}
process.exit(1);
}
});

agentCommand
.command("sessions")
.description("List saved conversation sessions")
.option("--json", "Output JSON")
.action(async (opts) => {
try {
const state = new StateManager(findProjectRoot());
const sessions = await state.listAgentSessions();

if (opts.json) {
log(JSON.stringify({ sessions }, null, 2));
return;
}

if (sessions.length === 0) {
log("No saved agent sessions.");
return;
}

for (const session of sessions) {
log(`${session.id} | messages: ${session.messageCount} | updated: ${session.updatedAt}`);
}
} catch (e) {
if (opts.json) {
log(JSON.stringify({ error: String(e) }));
} else {
logError(`Failed to list agent sessions: ${e}`);
}
process.exit(1);
}
});

agentCommand
.command("clear")
.description("Delete saved conversation history for a session")
.argument("[session-id]", "Session ID", "default")
.action(async (sessionId: string) => {
try {
const state = new StateManager(findProjectRoot());
await state.deleteAgentSession(sessionId);
log(`Cleared session \"${sessionId}\".`);
} catch (e) {
logError(`Failed to clear agent session: ${e}`);
process.exit(1);
}
});
Loading