openshain 0.1.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.
@@ -0,0 +1,137 @@
1
+ import { pendingQuestions, runWork } from "@openshain/agent";
2
+ import {
3
+ type AnyEvent,
4
+ createRuntime,
5
+ type Event,
6
+ type Runtime,
7
+ type RuntimeProviders,
8
+ type ToolContent,
9
+ type Work,
10
+ type WorkId,
11
+ } from "@openshain/core";
12
+ import { describeInput, truncate } from "../format.ts";
13
+ import { failureLabel, rejectionLabel, statusLabel } from "../labels.ts";
14
+ import { formatUsage, summarizeUsage } from "../usage.ts";
15
+
16
+ export interface DriveOptions {
17
+ write: (line: string) => void;
18
+ /** Answers the model's questions. Without it, a question leaves the work waiting and the run ends. */
19
+ ask?: (question: string) => Promise<string>;
20
+ /** Stops the run. The work stays where it is and can be resumed. */
21
+ signal?: AbortSignal;
22
+ }
23
+
24
+ export interface RunOptions extends DriveOptions {
25
+ workspaceRoot: string;
26
+ providers: RuntimeProviders;
27
+ objective: string;
28
+ }
29
+
30
+ /** Creates a work for the request and drives it. Exit code 0 when the work completed. */
31
+ export async function run(options: RunOptions): Promise<number> {
32
+ const runtime = await createRuntime({
33
+ workspaceRoot: options.workspaceRoot,
34
+ providers: options.providers,
35
+ });
36
+ const work = await runtime.works.create({
37
+ objective: options.objective,
38
+ principal: runtime.config.principal.id,
39
+ profession: runtime.config.profession.id,
40
+ });
41
+ options.write(`${work.id} を開始`);
42
+ return drive(runtime, work.id, options);
43
+ }
44
+
45
+ /** Drives a work from its current state, printing one line per tool call, and closes with the report. */
46
+ export async function drive(
47
+ runtime: Runtime,
48
+ workId: WorkId,
49
+ options: DriveOptions,
50
+ ): Promise<number> {
51
+ const names = new Map<string, string>();
52
+ const done = await runWork(runtime, workId, {
53
+ ...(options.ask && { onInput: options.ask }),
54
+ ...(options.signal && { signal: options.signal }),
55
+ onEvent: (event) => {
56
+ const line = progressLine(event, names);
57
+ if (line) options.write(line);
58
+ },
59
+ });
60
+ const events = await runtime.works.events(workId);
61
+ for (const line of report(done, events)) options.write(line);
62
+ return done.status === "completed" ? 0 : 1;
63
+ }
64
+
65
+ /** One line for a tool call, a rejection or a failure; nothing for the other events. `names` maps call ids to tool names. */
66
+ export function progressLine(event: AnyEvent, names: Map<string, string>): string | undefined {
67
+ switch (event.type) {
68
+ case "tool.called": {
69
+ const { callId, name, input } = (event as Event<"tool.called">).payload;
70
+ names.set(callId, name);
71
+ return `${name} ${describeInput(input)}`.trimEnd();
72
+ }
73
+ case "tool.rejected": {
74
+ const { name, code, reason } = (event as Event<"tool.rejected">).payload;
75
+ return `${name} は拒否されました。${rejectionLabel(code)}。${reason}`;
76
+ }
77
+ case "tool.completed": {
78
+ const { callId, content, isError } = (event as Event<"tool.completed">).payload;
79
+ if (!isError) return undefined;
80
+ return `${names.get(callId) ?? callId} は失敗しました。${truncate(firstLine(content))}`;
81
+ }
82
+ default:
83
+ return undefined;
84
+ }
85
+ }
86
+
87
+ function firstLine(content: ToolContent[]): string {
88
+ for (const part of content) {
89
+ if (part.type === "text") return part.text.split("\n")[0] ?? "";
90
+ }
91
+ return "";
92
+ }
93
+
94
+ /** The closing lines: what happened, what it cost, and who acts next. */
95
+ export function report(work: Work, events: AnyEvent[]): string[] {
96
+ const lines: string[] = [];
97
+ switch (work.status) {
98
+ case "completed":
99
+ lines.push(`完了。${work.outcome?.summary ?? ""}`.trimEnd());
100
+ for (const artifact of work.outcome?.artifacts ?? []) {
101
+ lines.push(
102
+ ` 書き込み ${artifact.path}${artifact.missing ? " 完了時には読めなかった" : ""}${artifact.claimed ? " エージェントの申告(この Work の Tool は書いていない)" : ""}`,
103
+ );
104
+ }
105
+ break;
106
+ case "failed":
107
+ lines.push(
108
+ `失敗。${failureLabel(work.failure?.reason)}。${work.failure?.detail ?? ""}`.trimEnd(),
109
+ );
110
+ break;
111
+ case "waiting_input":
112
+ lines.push("利用者の入力を待っています。");
113
+ for (const { question } of pendingQuestions(events)) lines.push(` 質問 ${question}`);
114
+ break;
115
+ default:
116
+ lines.push(`状態は ${statusLabel(work.status)} です。`);
117
+ }
118
+ lines.push(formatUsage(summarizeUsage(events)));
119
+ lines.push(nextActor(work));
120
+ return lines;
121
+ }
122
+
123
+ export function nextActor(work: Work): string {
124
+ switch (work.status) {
125
+ case "completed":
126
+ case "cancelled":
127
+ return "次に動く人はいません。";
128
+ case "waiting_input":
129
+ return `次は利用者の番です。openshain work resume ${work.id} で質問に答えると続きます。`;
130
+ case "waiting_approval":
131
+ return "次は利用者の番です。承認が要ります。";
132
+ case "failed":
133
+ return "次は利用者の番です。原因を直して、もう一度依頼してください。";
134
+ default:
135
+ return "次は model の番です。";
136
+ }
137
+ }
@@ -0,0 +1,32 @@
1
+ import { ASK_USER, RUNTIME_PROVIDER_ID } from "@openshain/agent";
2
+ import { createToolRegistry, loadConfig, type RuntimeProviders } from "@openshain/core";
3
+
4
+ export interface ToolsListOptions {
5
+ workspaceRoot: string;
6
+ providers: RuntimeProviders;
7
+ write: (line: string) => void;
8
+ }
9
+
10
+ /** Every tool the model can call in this workspace, and the ones the allow lists hide. Needs no model provider. */
11
+ export async function toolsList({
12
+ workspaceRoot,
13
+ providers,
14
+ write,
15
+ }: ToolsListOptions): Promise<void> {
16
+ const config = await loadConfig(workspaceRoot);
17
+ const registry = await createToolRegistry(workspaceRoot, config, providers.tools);
18
+ const rows: [string, string, string, string][] = registry
19
+ .list()
20
+ .map((t) => [t.definition.name, t.providerId, t.definition.effect, "許可"]);
21
+ rows.push([ASK_USER.name, RUNTIME_PROVIDER_ID, ASK_USER.effect, "許可"]);
22
+ for (const hidden of registry.hiddenTools()) {
23
+ rows.push([hidden.name, hidden.providerId, hidden.effect, "不許可"]);
24
+ }
25
+ const width = Math.max(...rows.map(([name]) => name.length));
26
+ const providerWidth = Math.max(...rows.map(([, provider]) => provider.length));
27
+ for (const [name, provider, effect, allowed] of rows) {
28
+ write(
29
+ `${name.padEnd(width)} ${provider.padEnd(providerWidth)} ${effect.padEnd(7)} ${allowed}`,
30
+ );
31
+ }
32
+ }
@@ -0,0 +1,137 @@
1
+ import { pendingQuestions } from "@openshain/agent";
2
+ import {
3
+ type AnyEvent,
4
+ createRuntime,
5
+ type Event,
6
+ isTerminal,
7
+ parseWorkId,
8
+ type RuntimeProviders,
9
+ SESSION_WORK_TYPE,
10
+ type Work,
11
+ WorkStore,
12
+ } from "@openshain/core";
13
+ import { describeInput, padDisplay } from "../format.ts";
14
+ import { errorLabel, failureLabel, rejectionLabel, statusLabel } from "../labels.ts";
15
+ import { formatUsage, summarizeUsage } from "../usage.ts";
16
+ import { type DriveOptions, drive, nextActor } from "./run.ts";
17
+
18
+ export interface WorkListOptions {
19
+ workspaceRoot: string;
20
+ write: (line: string) => void;
21
+ }
22
+
23
+ /** One line per work, oldest first. Works that cannot be read are reported, not hidden. */
24
+ export async function workList({ workspaceRoot, write }: WorkListOptions): Promise<void> {
25
+ const { works, problems } = await new WorkStore(workspaceRoot).list();
26
+ if (works.length === 0 && problems.length === 0) {
27
+ write('Work はまだありません。openshain run "<依頼>" で始められます。');
28
+ return;
29
+ }
30
+ for (const work of works) {
31
+ write(
32
+ `${work.id} ${padDisplay(statusLabel(work.status), 16)} ${work.createdAt.slice(0, 16)} ${shorten(work.objective)}`,
33
+ );
34
+ }
35
+ for (const { id, error } of problems) {
36
+ write(`${id} 読めない(${errorLabel(error.code) ?? error.code}) ${error.message}`);
37
+ }
38
+ }
39
+
40
+ export interface WorkShowOptions {
41
+ workspaceRoot: string;
42
+ id: string;
43
+ write: (line: string) => void;
44
+ }
45
+
46
+ /** Everything about one work: state, outcome, what the tools did, the usage, and who acts next. */
47
+ export async function workShow({ workspaceRoot, id, write }: WorkShowOptions): Promise<void> {
48
+ const store = new WorkStore(workspaceRoot);
49
+ const workId = parseWorkId(id);
50
+ const work = await store.get(workId);
51
+ const events = await store.events(workId);
52
+ for (const line of describeWork(work, events)) write(line);
53
+ }
54
+
55
+ export function describeWork(work: Work, events: AnyEvent[]): string[] {
56
+ const lines = [
57
+ `${work.id}`,
58
+ `状態 ${statusLabel(work.status)}(${work.status})`,
59
+ `依頼 ${work.objective}`,
60
+ `作成 ${work.createdAt}`,
61
+ ];
62
+ if (work.agentName) lines.push(`名前 ${work.agentName}(社員エージェント)`);
63
+ if (work.startedAt) lines.push(`開始 ${work.startedAt}`);
64
+ if (work.completedAt) lines.push(`終了 ${work.completedAt}`);
65
+ if (work.outcome) {
66
+ lines.push(`結果 ${work.outcome.summary}`);
67
+ for (const artifact of work.outcome.artifacts)
68
+ lines.push(
69
+ ` 書き込み ${artifact.path} ${artifact.sha256.slice(0, 12)}${artifact.missing ? " 完了時には読めなかった" : ""}${artifact.claimed ? " エージェントの申告(この Work の Tool は書いていない)" : ""}`,
70
+ );
71
+ }
72
+ if (work.failure) {
73
+ lines.push(`失敗 ${failureLabel(work.failure.reason)}。${work.failure.detail}`);
74
+ }
75
+ if (work.status === "waiting_input") {
76
+ for (const { question } of pendingQuestions(events)) lines.push(`質問 ${question}`);
77
+ }
78
+
79
+ const calls = toolLines(events);
80
+ if (calls.length > 0) {
81
+ lines.push("Tool");
82
+ for (const line of calls) lines.push(line);
83
+ }
84
+ lines.push(formatUsage(summarizeUsage(events)));
85
+ lines.push(nextActor(work));
86
+ return lines;
87
+ }
88
+
89
+ export interface WorkResumeOptions extends DriveOptions {
90
+ workspaceRoot: string;
91
+ providers: RuntimeProviders;
92
+ id: string;
93
+ }
94
+
95
+ /** Continues a work that stopped before its end, answering its questions when the caller can. */
96
+ export async function workResume(options: WorkResumeOptions): Promise<number> {
97
+ const workId = parseWorkId(options.id);
98
+ const work = await new WorkStore(options.workspaceRoot).get(workId);
99
+ if (work.type === SESSION_WORK_TYPE) {
100
+ options.write(
101
+ `${work.id} は会話の記録のため、再開できません。会話は openshain で始め直してください。`,
102
+ );
103
+ return 1;
104
+ }
105
+ if (isTerminal(work.status)) {
106
+ options.write(`${work.id} は${statusLabel(work.status)}のため、再開できません。`);
107
+ return 1;
108
+ }
109
+ const runtime = await createRuntime({
110
+ workspaceRoot: options.workspaceRoot,
111
+ providers: options.providers,
112
+ });
113
+ return drive(runtime, workId, options);
114
+ }
115
+
116
+ /** One line per tool call, in log order, with its outcome when it was rejected or failed. */
117
+ function toolLines(events: AnyEvent[]): string[] {
118
+ const lines = new Map<string, string>();
119
+ for (const event of events) {
120
+ if (event.type === "tool.called") {
121
+ const { callId, name, input } = (event as Event<"tool.called">).payload;
122
+ lines.set(callId, ` ${name} ${describeInput(input)}`.trimEnd());
123
+ } else if (event.type === "tool.rejected") {
124
+ const { callId, name, code } = (event as Event<"tool.rejected">).payload;
125
+ lines.set(callId, `${lines.get(callId) ?? ` ${name}`} 拒否(${rejectionLabel(code)})`);
126
+ } else if (event.type === "tool.completed") {
127
+ const { callId, isError } = (event as Event<"tool.completed">).payload;
128
+ if (isError) lines.set(callId, `${lines.get(callId) ?? ` ${callId}`} 失敗`);
129
+ }
130
+ }
131
+ return [...lines.values()];
132
+ }
133
+
134
+ function shorten(text: string): string {
135
+ const oneLine = text.replace(/\s+/g, " ").trim();
136
+ return oneLine.length > 40 ? `${oneLine.slice(0, 39)}…` : oneLine;
137
+ }
package/src/format.ts ADDED
@@ -0,0 +1,106 @@
1
+ /** Display width in a terminal: East Asian wide and full-width characters take two columns. */
2
+ export function displayWidth(text: string): number {
3
+ let width = 0;
4
+ for (const ch of text) width += isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
5
+ return width;
6
+ }
7
+
8
+ function isWide(code: number): boolean {
9
+ return (
10
+ (code >= 0x1100 && code <= 0x115f) ||
11
+ (code >= 0x2e80 && code <= 0xa4cf) ||
12
+ (code >= 0xac00 && code <= 0xd7a3) ||
13
+ (code >= 0xf900 && code <= 0xfaff) ||
14
+ (code >= 0xfe30 && code <= 0xfe4f) ||
15
+ (code >= 0xff00 && code <= 0xff60) ||
16
+ (code >= 0xffe0 && code <= 0xffe6) ||
17
+ (code >= 0x20000 && code <= 0x3fffd)
18
+ );
19
+ }
20
+
21
+ /** Pads with spaces to a display width, so columns line up with Japanese text in them. */
22
+ export function padDisplay(text: string, width: number): string {
23
+ const missing = width - displayWidth(text);
24
+ return missing > 0 ? text + " ".repeat(missing) : text;
25
+ }
26
+
27
+ /** A tool input on one line: the path when there is one, nothing for a question, otherwise the JSON, shortened. */
28
+ export function describeInput(input: unknown): string {
29
+ if (input && typeof input === "object") {
30
+ if ("path" in input) return String((input as { path: unknown }).path);
31
+ if ("question" in input) return "";
32
+ }
33
+ return truncate(JSON.stringify(input) ?? "");
34
+ }
35
+
36
+ export function truncate(text: string, max = 80): string {
37
+ return text.length > max ? `${text.slice(0, max - 3)}...` : text;
38
+ }
39
+
40
+ /**
41
+ * Text as it may reach a terminal: escape sequences and other control characters are dropped,
42
+ * newline and tab stay. Nothing a model says or a file contains can then move the cursor,
43
+ * retitle the window or write the clipboard. Invisible formatting characters (zero-width and
44
+ * bidirectional controls) go too, so a line cannot be made to read differently from what it is.
45
+ * A scan over the characters, not a regular expression, so the time is linear in the text.
46
+ */
47
+ export function plain(text: string): string {
48
+ const chars = [...text];
49
+ let out = "";
50
+ let i = 0;
51
+ while (i < chars.length) {
52
+ const ch = chars[i] as string;
53
+ const code = ch.codePointAt(0) ?? 0;
54
+ if (code === ESC || code === CSI) i = afterSequence(chars, i);
55
+ else {
56
+ if (!isControl(code)) out += ch;
57
+ i += 1;
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ const ESC = 0x1b;
64
+ const CSI = 0x9b;
65
+ const BEL = 0x07;
66
+ const ST = 0x9c;
67
+
68
+ function isControl(code: number): boolean {
69
+ return (
70
+ (code < 0x20 && code !== 0x0a && code !== 0x09) ||
71
+ (code >= 0x7f && code <= 0x9f) ||
72
+ (code >= 0x200b && code <= 0x200f) ||
73
+ (code >= 0x202a && code <= 0x202e) ||
74
+ (code >= 0x2066 && code <= 0x2069) ||
75
+ code === 0xfeff
76
+ );
77
+ }
78
+
79
+ /** The index after the escape sequence that starts at `start`. An unfinished one runs to the end. */
80
+ function afterSequence(chars: string[], start: number): number {
81
+ const at = (i: number) => chars[i]?.codePointAt(0) ?? -1;
82
+ const end = chars.length;
83
+ const opener = at(start) === CSI ? "[" : chars[start + 1];
84
+ let i = at(start) === CSI ? start + 1 : start + 2;
85
+ if (opener === "[") {
86
+ // CSI: parameter and intermediate bytes, then one final byte.
87
+ while (i < end && at(i) >= 0x20 && at(i) <= 0x3f) i += 1;
88
+ return Math.min(i + 1, end);
89
+ }
90
+ if (opener === "]" || opener === "P" || opener === "X" || opener === "^" || opener === "_") {
91
+ // OSC, DCS, SOS, PM, APC: a string that ends with BEL or ST.
92
+ while (i < end) {
93
+ if (at(i) === BEL || at(i) === ST) return i + 1;
94
+ if (at(i) === ESC && chars[i + 1] === "\\") return i + 2;
95
+ i += 1;
96
+ }
97
+ return end;
98
+ }
99
+ if (at(start + 1) >= 0x20 && at(start + 1) <= 0x2f) {
100
+ // Intermediate bytes, then one final byte.
101
+ while (i < end && at(i) >= 0x20 && at(i) <= 0x2f) i += 1;
102
+ return Math.min(i + 1, end);
103
+ }
104
+ // ESC and one final character.
105
+ return Math.min(i, end);
106
+ }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ // openshain: Reference CLI agent for the openshain runtime
2
+ export {
3
+ AGENTS_TEMPLATE,
4
+ CLAUDE_TEMPLATE,
5
+ CONFIG_TEMPLATE,
6
+ type InitOptions,
7
+ init,
8
+ MCP_TEMPLATE,
9
+ } from "./commands/init.ts";
10
+ export { type McpOptions, mcp } from "./commands/mcp.ts";
11
+ export {
12
+ type DriveOptions,
13
+ drive,
14
+ nextActor,
15
+ type RunOptions,
16
+ report,
17
+ run,
18
+ } from "./commands/run.ts";
19
+ export { type ToolsListOptions, toolsList } from "./commands/tools.ts";
20
+ export {
21
+ describeWork,
22
+ type WorkListOptions,
23
+ type WorkResumeOptions,
24
+ type WorkShowOptions,
25
+ workList,
26
+ workResume,
27
+ workShow,
28
+ } from "./commands/work.ts";
29
+ export {
30
+ ERROR_LABELS,
31
+ errorLabel,
32
+ FAILURE_LABELS,
33
+ failureLabel,
34
+ REJECTION_LABELS,
35
+ rejectionLabel,
36
+ STATUS_LABELS,
37
+ statusLabel,
38
+ } from "./labels.ts";
39
+ export { formatUsage, summarizeUsage, type UsageSummary } from "./usage.ts";
40
+ export { findWorkspace } from "./workspace.ts";
package/src/labels.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { FailureReason } from "@openshain/agent";
2
+ import type { ErrorCode, ToolRejectionCode, WorkStatus } from "@openshain/core";
3
+
4
+ /** The words shown for a work's status. The log keeps the original value. */
5
+ export const STATUS_LABELS: Record<WorkStatus, string> = {
6
+ queued: "未着手",
7
+ in_progress: "進行中",
8
+ waiting_input: "利用者の入力待ち",
9
+ waiting_approval: "承認待ち",
10
+ waiting_external: "外部の応答待ち",
11
+ completed: "完了",
12
+ failed: "失敗",
13
+ cancelled: "取り消し",
14
+ };
15
+
16
+ /** Why a work failed, as a heading before the original detail. */
17
+ export const FAILURE_LABELS: Record<FailureReason, string> = {
18
+ limit_reached: "上限到達",
19
+ model_refusal: "model の拒否",
20
+ model_error: "model のエラー",
21
+ };
22
+
23
+ /** Why a tool call was rejected, as a heading before the original reason. */
24
+ export const REJECTION_LABELS: Record<ToolRejectionCode, string> = {
25
+ schema_mismatch: "schema に合わない入力",
26
+ unknown_tool: "知らない Tool",
27
+ not_allowed: "この workspace では不許可",
28
+ reserved_path: "予約されたパス",
29
+ outside_workspace: "workspace の外",
30
+ invalid_path: "不正なパス",
31
+ };
32
+
33
+ /** A heading for a runtime error, before the original message. */
34
+ export const ERROR_LABELS: Record<ErrorCode, string> = {
35
+ auth: "認証の失敗",
36
+ network: "接続の失敗",
37
+ rate_limit: "呼び出し上限",
38
+ invalid_response: "解釈できない model の応答",
39
+ config: "設定の問題",
40
+ corrupt_log: "壊れた Work の記録",
41
+ invalid_transition: "進められない状態",
42
+ duplicate_tool: "同じ名前の Tool の重複",
43
+ invalid_id: "不正な id",
44
+ invalid_tool: "不正な Tool の定義",
45
+ invalid_path: "不正なパス",
46
+ lock_held: "別のプロセスが使用中",
47
+ not_found: "対象なし",
48
+ reserved_path: "予約されたパス",
49
+ outside_workspace: "workspace の外",
50
+ concurrent_write: "同時書き込み",
51
+ invalid_event: "記録できないイベント",
52
+ };
53
+
54
+ export function statusLabel(status: string): string {
55
+ return (STATUS_LABELS as Record<string, string>)[status] ?? status;
56
+ }
57
+
58
+ export function failureLabel(reason: string | undefined): string {
59
+ return reason ? ((FAILURE_LABELS as Record<string, string>)[reason] ?? reason) : "理由は不明";
60
+ }
61
+
62
+ export function rejectionLabel(code: string): string {
63
+ return (REJECTION_LABELS as Record<string, string>)[code] ?? code;
64
+ }
65
+
66
+ export function errorLabel(code: string): string | undefined {
67
+ return (ERROR_LABELS as Record<string, string>)[code];
68
+ }