openshain 0.1.1 → 0.3.1
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/dist/bin.d.ts +2 -0
- package/dist/bin.js +108 -0
- package/dist/commands/init.d.ts +21 -0
- package/dist/commands/init.js +128 -0
- package/dist/commands/mcp.d.ts +10 -0
- package/dist/commands/mcp.js +17 -0
- package/dist/commands/tools.d.ts +8 -0
- package/dist/commands/tools.js +18 -0
- package/dist/commands/work.d.ts +15 -0
- package/dist/commands/work.js +87 -0
- package/dist/format.d.ts +15 -0
- package/dist/format.js +103 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/labels.d.ts +13 -0
- package/dist/labels.js +59 -0
- package/dist/report.d.ts +6 -0
- package/dist/report.js +73 -0
- package/dist/tui/app.d.ts +9 -0
- package/dist/tui/app.js +191 -0
- package/dist/tui/banner.d.ts +15 -0
- package/dist/tui/banner.js +29 -0
- package/dist/tui/controller.d.ts +59 -0
- package/dist/tui/controller.js +325 -0
- package/dist/tui/index.d.ts +7 -0
- package/dist/tui/index.js +45 -0
- package/dist/tui/lines.d.ts +12 -0
- package/dist/tui/lines.js +68 -0
- package/dist/usage.d.ts +12 -0
- package/dist/usage.js +32 -0
- package/dist/workspace.d.ts +2 -0
- package/dist/workspace.js +20 -0
- package/package.json +17 -7
- package/src/bin.ts +5 -39
- package/src/commands/init.ts +9 -9
- package/src/commands/tools.ts +7 -2
- package/src/commands/work.ts +3 -34
- package/src/index.ts +1 -10
- package/src/labels.ts +2 -2
- package/src/{commands/run.ts → report.ts} +6 -59
- package/src/tui/controller.ts +113 -75
- package/src/tui/index.ts +3 -1
- package/src/usage.ts +1 -2
- package/src/workspace.ts +1 -1
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { connectInMemory, createSession } from "@openshain/agent";
|
|
2
|
+
import { loadConfig, OpenshainError, WorkStore, } from "@openshain/core";
|
|
3
|
+
import { createMcpServer } from "@openshain/mcp";
|
|
4
|
+
import { toolsList } from "../commands/tools.js";
|
|
5
|
+
import { workList, workShow } from "../commands/work.js";
|
|
6
|
+
import { plain } from "../format.js";
|
|
7
|
+
import { statusLabel } from "../labels.js";
|
|
8
|
+
import { progressLine, report } from "../report.js";
|
|
9
|
+
import { LOGO_ROWS, VERSION } from "./banner.js";
|
|
10
|
+
const HELP = [
|
|
11
|
+
"/work list Work の一覧",
|
|
12
|
+
"/work show <id> Work の詳細",
|
|
13
|
+
"/work resume <id> 止まった Work を候補にする。次の依頼がそれに沿えば続ける",
|
|
14
|
+
"/tools 使える Tool",
|
|
15
|
+
"/quit 終わる",
|
|
16
|
+
"↑ ↓ 前に送った行を入力欄に呼び戻す。いちばん下は新しい入力",
|
|
17
|
+
"← → Home End 入力欄でカーソルを動かす。Backspace と Delete はカーソルの位置で消す",
|
|
18
|
+
"ホイール、PageUp/PageDown 会話を遡る。送ると最新に戻る",
|
|
19
|
+
"Ctrl-C 動いている Work を止める。質問待ちなら質問を取り下げる。何も動いていなければ終わる",
|
|
20
|
+
];
|
|
21
|
+
/** What the session's model hears when the person stops a work that waits for their answer. */
|
|
22
|
+
const QUESTION_WITHDRAWN = "the person stopped the work while it waited for their answer; the question is still pending and the work can be resumed";
|
|
23
|
+
/**
|
|
24
|
+
* The state behind the screen: a session, the works it starts, and the lines to show. The
|
|
25
|
+
* conversation reaches the runtime only as an MCP client of the workspace's own server, the way
|
|
26
|
+
* any other agent does; the records are read directly for the closing lines.
|
|
27
|
+
*/
|
|
28
|
+
export async function createController(options) {
|
|
29
|
+
const { workspaceRoot, providers } = options;
|
|
30
|
+
const config = await loadConfig(workspaceRoot, { modelProviders: Object.keys(providers.models) });
|
|
31
|
+
if (!config.model) {
|
|
32
|
+
throw new OpenshainError("config", "対話にはモデルが要ります。openshain.yaml に model を書いてください。Claude Code や Codex から使うだけなら要りません。");
|
|
33
|
+
}
|
|
34
|
+
const modelFactory = Object.hasOwn(providers.models, config.model.provider)
|
|
35
|
+
? providers.models[config.model.provider]
|
|
36
|
+
: undefined;
|
|
37
|
+
if (!modelFactory) {
|
|
38
|
+
throw new OpenshainError("config", `unknown model provider "${config.model.provider}"`);
|
|
39
|
+
}
|
|
40
|
+
const model = modelFactory(config.model);
|
|
41
|
+
if (!model.describe().capabilities.tools) {
|
|
42
|
+
throw new OpenshainError("config", `model ${config.model.provider}/${config.model.model} cannot call tools; openshain needs a model with tool support`);
|
|
43
|
+
}
|
|
44
|
+
const server = await createMcpServer({ workspaceRoot, tools: providers.tools });
|
|
45
|
+
const client = await connectInMemory(server);
|
|
46
|
+
const store = new WorkStore(workspaceRoot);
|
|
47
|
+
const listeners = new Set();
|
|
48
|
+
let nextId = 1;
|
|
49
|
+
const state = {
|
|
50
|
+
entries: [],
|
|
51
|
+
busy: false,
|
|
52
|
+
closed: false,
|
|
53
|
+
status: {
|
|
54
|
+
company: config.company.name,
|
|
55
|
+
model: `${config.model.provider}/${config.model.model}`,
|
|
56
|
+
usage: { modelCalls: 0, inputTokens: 0, outputTokens: 0 },
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
// A listener may act on the controller and cause another notification; those run after this one.
|
|
60
|
+
let notifying = false;
|
|
61
|
+
let again = false;
|
|
62
|
+
const notify = () => {
|
|
63
|
+
if (notifying) {
|
|
64
|
+
again = true;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
notifying = true;
|
|
68
|
+
try {
|
|
69
|
+
do {
|
|
70
|
+
again = false;
|
|
71
|
+
for (const listener of listeners)
|
|
72
|
+
listener();
|
|
73
|
+
} while (again);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
notifying = false;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const push = (kind, text) => {
|
|
80
|
+
state.entries = [...state.entries, { id: nextId++, kind, text: plain(text) }];
|
|
81
|
+
notify();
|
|
82
|
+
};
|
|
83
|
+
let pending;
|
|
84
|
+
let aborter;
|
|
85
|
+
let running;
|
|
86
|
+
let closing;
|
|
87
|
+
const names = new Map();
|
|
88
|
+
const ask = (workId, question) => {
|
|
89
|
+
state.question = question;
|
|
90
|
+
push("question", `${question}(${workId})`);
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
pending = { resolve, reject };
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
/** Answers the pending question, or takes it back when there is no answer. */
|
|
96
|
+
const settleQuestion = (answer) => {
|
|
97
|
+
const waiting = pending;
|
|
98
|
+
pending = undefined;
|
|
99
|
+
if (state.question !== undefined) {
|
|
100
|
+
delete state.question;
|
|
101
|
+
notify();
|
|
102
|
+
}
|
|
103
|
+
if (!waiting)
|
|
104
|
+
return;
|
|
105
|
+
if (answer === undefined)
|
|
106
|
+
waiting.reject(new Error(QUESTION_WITHDRAWN));
|
|
107
|
+
else
|
|
108
|
+
waiting.resolve(answer);
|
|
109
|
+
};
|
|
110
|
+
/** The lines the CLI prints when a work ends, shown among the progress lines. */
|
|
111
|
+
const closingLines = async (workId) => {
|
|
112
|
+
for (const line of await workReport(store, workId))
|
|
113
|
+
push("progress", line.trimStart());
|
|
114
|
+
};
|
|
115
|
+
let sessionId;
|
|
116
|
+
const session = await createSession(client, {
|
|
117
|
+
model,
|
|
118
|
+
config,
|
|
119
|
+
onEvent: (workId, event) => {
|
|
120
|
+
if (workId === sessionId) {
|
|
121
|
+
if (event.type === "usage.recorded") {
|
|
122
|
+
const { payload } = event;
|
|
123
|
+
if (payload.kind === "model_inference") {
|
|
124
|
+
state.status.usage.modelCalls += 1;
|
|
125
|
+
state.status.usage.inputTokens += payload.usage.inputTokens;
|
|
126
|
+
state.status.usage.outputTokens += payload.usage.outputTokens;
|
|
127
|
+
notify();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (event.type === "work.status_changed") {
|
|
133
|
+
state.status.work = {
|
|
134
|
+
id: workId,
|
|
135
|
+
status: event.payload.to,
|
|
136
|
+
};
|
|
137
|
+
notify();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (event.type === "work.completed" || event.type === "work.failed") {
|
|
141
|
+
state.status.work = {
|
|
142
|
+
id: workId,
|
|
143
|
+
status: event.type === "work.completed" ? "completed" : "failed",
|
|
144
|
+
};
|
|
145
|
+
return closingLines(workId);
|
|
146
|
+
}
|
|
147
|
+
// The work_* calls are the loop's own bookkeeping; the closing lines already say the work ended.
|
|
148
|
+
if (event.type === "tool.called" &&
|
|
149
|
+
event.payload.name.startsWith("work_")) {
|
|
150
|
+
names.set(event.payload.callId, event.payload.name);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const line = progressLine(event, names);
|
|
154
|
+
if (line)
|
|
155
|
+
push("progress", line);
|
|
156
|
+
else
|
|
157
|
+
notify();
|
|
158
|
+
},
|
|
159
|
+
onInput: ask,
|
|
160
|
+
});
|
|
161
|
+
sessionId = session.id;
|
|
162
|
+
state.status.agentName = session.agentName;
|
|
163
|
+
for (const row of LOGO_ROWS)
|
|
164
|
+
push("logo", row);
|
|
165
|
+
push("banner", `openshain ${VERSION}`);
|
|
166
|
+
push("banner", workspaceRoot);
|
|
167
|
+
const stopped = (workId) => workId
|
|
168
|
+
? `止めました。${workId} は途中のまま残っています。/work resume ${workId} で続けられるようにします。`
|
|
169
|
+
: "止めました。";
|
|
170
|
+
const explain = (result) => {
|
|
171
|
+
switch (result.stopped) {
|
|
172
|
+
case "turn_limit":
|
|
173
|
+
return "社員エージェントが 1 回の返答でできる回数を超えたので、ここで止めました。続きは改めて依頼してください。";
|
|
174
|
+
case "aborted":
|
|
175
|
+
return stopped(result.work);
|
|
176
|
+
case "max_tokens":
|
|
177
|
+
return "返答が長さの上限で切れました。";
|
|
178
|
+
case "refusal":
|
|
179
|
+
return "社員エージェントが続けられないと言っています。";
|
|
180
|
+
case "model_error":
|
|
181
|
+
return `model の呼び出しに失敗しました。${result.detail ?? ""}`.trim();
|
|
182
|
+
default:
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
const message = (err) => (err instanceof Error ? err.message : String(err));
|
|
187
|
+
/** Runs one thing the person can stop with Ctrl-C, and keeps the screen busy meanwhile. */
|
|
188
|
+
const stoppable = async (fn) => {
|
|
189
|
+
const stopper = new AbortController();
|
|
190
|
+
aborter = stopper;
|
|
191
|
+
state.busy = true;
|
|
192
|
+
notify();
|
|
193
|
+
running = fn(stopper.signal);
|
|
194
|
+
try {
|
|
195
|
+
await running;
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
running = undefined;
|
|
199
|
+
aborter = undefined;
|
|
200
|
+
state.busy = false;
|
|
201
|
+
notify();
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
const capture = async (fn) => {
|
|
205
|
+
try {
|
|
206
|
+
await fn((line) => push("line", line));
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
push("notice", message(err));
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
const command = async (line) => {
|
|
213
|
+
const [name, ...args] = line.slice(1).trim().split(/\s+/);
|
|
214
|
+
const sub = args[0] ?? "";
|
|
215
|
+
const id = args[1] ?? args[0] ?? "";
|
|
216
|
+
if (name === "help")
|
|
217
|
+
for (const h of HELP)
|
|
218
|
+
push("line", h);
|
|
219
|
+
else if (name === "quit" || name === "exit")
|
|
220
|
+
await close();
|
|
221
|
+
else if (name === "tools")
|
|
222
|
+
await capture((write) => toolsList({ ...options, write }));
|
|
223
|
+
else if (name === "work" && sub === "list")
|
|
224
|
+
await capture((write) => workList({ workspaceRoot: options.workspaceRoot, write }));
|
|
225
|
+
else if (name === "work" && (sub === "show" || sub === "resume") && !args[1])
|
|
226
|
+
push("notice", `/work ${sub} には Work の id が要ります。/work list で確かめてください。`);
|
|
227
|
+
else if (name === "work" && sub === "show" && id)
|
|
228
|
+
await capture((write) => workShow({ workspaceRoot: options.workspaceRoot, id, write }));
|
|
229
|
+
else if (name === "work" && sub === "resume" && id) {
|
|
230
|
+
try {
|
|
231
|
+
const work = await session.select(id);
|
|
232
|
+
push("notice", `${work.id}(${statusLabel(work.status)}、${work.objective})を候補にしました。次の依頼がこの Work に沿えば続けます。`);
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
push("notice", message(err));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else if (name === "resume") {
|
|
239
|
+
push("notice", "セッションの再開はまだありません。止まった Work を続けるなら /work resume <id> です。");
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
push("notice", `分からないコマンドです。/help で一覧が表示されます。`);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
function close() {
|
|
246
|
+
closing ??= (async () => {
|
|
247
|
+
aborter?.abort();
|
|
248
|
+
settleQuestion();
|
|
249
|
+
await running;
|
|
250
|
+
try {
|
|
251
|
+
await session.close();
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
push("notice", `会話の記録を閉じられませんでした。${message(err)}`);
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
await client.close().catch(() => undefined);
|
|
258
|
+
state.closed = true;
|
|
259
|
+
notify();
|
|
260
|
+
}
|
|
261
|
+
})();
|
|
262
|
+
return closing;
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
sessionId: session.id,
|
|
266
|
+
state: () => state,
|
|
267
|
+
subscribe(listener) {
|
|
268
|
+
listeners.add(listener);
|
|
269
|
+
return () => listeners.delete(listener);
|
|
270
|
+
},
|
|
271
|
+
async submit(line) {
|
|
272
|
+
const text = line.trim();
|
|
273
|
+
if (text === "" || closing)
|
|
274
|
+
return;
|
|
275
|
+
if (pending) {
|
|
276
|
+
push("user", text);
|
|
277
|
+
// Everything typed answers the question, except leaving: that takes the question back.
|
|
278
|
+
if (text === "/quit" || text === "/exit")
|
|
279
|
+
await close();
|
|
280
|
+
else
|
|
281
|
+
settleQuestion(text);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (state.busy) {
|
|
285
|
+
push("notice", "いま動いています。止めるなら Ctrl-C。");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (text.startsWith("/")) {
|
|
289
|
+
push("user", text);
|
|
290
|
+
await command(text);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
push("user", text);
|
|
294
|
+
await stoppable(async (signal) => {
|
|
295
|
+
try {
|
|
296
|
+
const result = await session.turn(text, { signal });
|
|
297
|
+
if (result.reply)
|
|
298
|
+
push("assistant", result.reply);
|
|
299
|
+
const note = explain(result);
|
|
300
|
+
if (note)
|
|
301
|
+
push("notice", note);
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
push("notice", message(err));
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
},
|
|
308
|
+
interrupt() {
|
|
309
|
+
if (!aborter)
|
|
310
|
+
return false;
|
|
311
|
+
aborter.abort();
|
|
312
|
+
settleQuestion();
|
|
313
|
+
return true;
|
|
314
|
+
},
|
|
315
|
+
close,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/** Lines that close a work in the screen: the CLI's closing lines without the summary, which the agent relays. */
|
|
319
|
+
export async function workReport(store, workId) {
|
|
320
|
+
const work = await store.get(workId);
|
|
321
|
+
const events = await store.events(workId);
|
|
322
|
+
const lines = report(work, events);
|
|
323
|
+
return work.status === "completed" ? ["完了。", ...lines.slice(1)] : lines;
|
|
324
|
+
}
|
|
325
|
+
export { statusLabel };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RuntimeProviders } from "@openshain/core";
|
|
2
|
+
export interface TuiOptions {
|
|
3
|
+
workspaceRoot: string;
|
|
4
|
+
providers: RuntimeProviders;
|
|
5
|
+
}
|
|
6
|
+
/** Opens the conversation screen and returns when the person leaves it. */
|
|
7
|
+
export declare function startTui(options: TuiOptions): Promise<number>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { render } from "ink";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { App } from "./app.js";
|
|
4
|
+
import { createController } from "./controller.js";
|
|
5
|
+
/** The alternate screen, cleared, with the terminal reporting the mouse (SGR) so the wheel reaches the screen. */
|
|
6
|
+
const ENTER_SCREEN = "\x1b[?1049h\x1b[H\x1b[2J\x1b[?1000h\x1b[?1006h";
|
|
7
|
+
const LEAVE_SCREEN = "\x1b[?1006l\x1b[?1000l\x1b[?1049l";
|
|
8
|
+
/** Opens the conversation screen and returns when the person leaves it. */
|
|
9
|
+
export async function startTui(options) {
|
|
10
|
+
const controller = await createController(options);
|
|
11
|
+
// Whatever ends the process, the terminal gets its screen and its mouse back: an error thrown
|
|
12
|
+
// while drawing, a signal, or a crash. Written once.
|
|
13
|
+
let left = false;
|
|
14
|
+
const leave = () => {
|
|
15
|
+
if (left)
|
|
16
|
+
return;
|
|
17
|
+
left = true;
|
|
18
|
+
process.stdout.write(LEAVE_SCREEN);
|
|
19
|
+
};
|
|
20
|
+
process.once("exit", leave);
|
|
21
|
+
process.stdout.write(ENTER_SCREEN);
|
|
22
|
+
try {
|
|
23
|
+
const { waitUntilExit } = render(React.createElement(App, { controller }), {
|
|
24
|
+
exitOnCtrlC: false,
|
|
25
|
+
});
|
|
26
|
+
// A closed terminal or a stop signal stops what runs and still ends the session in the record.
|
|
27
|
+
// Registered after render: Ink's own signal handling re-raises a signal when it thinks nobody
|
|
28
|
+
// else listens.
|
|
29
|
+
const closeAndExit = () => {
|
|
30
|
+
controller.close().finally(() => {
|
|
31
|
+
leave();
|
|
32
|
+
process.exit(0);
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
for (const signal of ["SIGHUP", "SIGTERM", "SIGINT"])
|
|
36
|
+
process.once(signal, closeAndExit);
|
|
37
|
+
await waitUntilExit();
|
|
38
|
+
await controller.close();
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
leave();
|
|
42
|
+
}
|
|
43
|
+
console.log(`会話を終えました。記録は openshain work show ${controller.sessionId} で参照します。`);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Segment } from "./banner.ts";
|
|
2
|
+
import type { Entry, EntryKind } from "./controller.ts";
|
|
3
|
+
export interface ScreenLine {
|
|
4
|
+
kind: EntryKind | "blank";
|
|
5
|
+
text: string;
|
|
6
|
+
/** Colored pieces of a logo row; the other rows are one color. */
|
|
7
|
+
segments?: Segment[];
|
|
8
|
+
}
|
|
9
|
+
/** Breaks text into lines no wider than `width` display columns, counting East Asian wide characters as two. */
|
|
10
|
+
export declare function wrapText(text: string, width: number): string[];
|
|
11
|
+
/** The rows the screen shows for the entries, wrapped to the width, with markers and blank rows between blocks. */
|
|
12
|
+
export declare function screenLines(entries: readonly Entry[], width: number): ScreenLine[];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { displayWidth } from "../format.js";
|
|
2
|
+
import { logoSegments } from "./banner.js";
|
|
3
|
+
/** What starts a line of each kind. The continuation lines of a wrapped entry are indented to match. */
|
|
4
|
+
const MARKERS = {
|
|
5
|
+
user: "> ",
|
|
6
|
+
assistant: "⏺ ",
|
|
7
|
+
progress: " ⎿ ",
|
|
8
|
+
notice: "! ",
|
|
9
|
+
question: "? ",
|
|
10
|
+
line: " ",
|
|
11
|
+
logo: "",
|
|
12
|
+
banner: "",
|
|
13
|
+
};
|
|
14
|
+
/** Breaks text into lines no wider than `width` display columns, counting East Asian wide characters as two. */
|
|
15
|
+
export function wrapText(text, width) {
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const paragraph of text.split("\n")) {
|
|
18
|
+
let line = "";
|
|
19
|
+
let used = 0;
|
|
20
|
+
for (const ch of paragraph) {
|
|
21
|
+
const w = displayWidth(ch);
|
|
22
|
+
if (used + w > width && line !== "") {
|
|
23
|
+
out.push(line);
|
|
24
|
+
line = "";
|
|
25
|
+
used = 0;
|
|
26
|
+
}
|
|
27
|
+
line += ch;
|
|
28
|
+
used += w;
|
|
29
|
+
}
|
|
30
|
+
out.push(line);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/** A blank row goes before an entry that starts something new: a message, a reply, a notice, a question. */
|
|
35
|
+
function startsBlock(kind, previous) {
|
|
36
|
+
if (previous === undefined)
|
|
37
|
+
return false;
|
|
38
|
+
if (kind === "logo" || kind === "banner")
|
|
39
|
+
return false;
|
|
40
|
+
if (kind === "progress")
|
|
41
|
+
return previous === "user";
|
|
42
|
+
if (kind === "line")
|
|
43
|
+
return previous !== "line";
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
/** The rows the screen shows for the entries, wrapped to the width, with markers and blank rows between blocks. */
|
|
47
|
+
export function screenLines(entries, width) {
|
|
48
|
+
const lines = [];
|
|
49
|
+
let previous;
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (startsBlock(entry.kind, previous))
|
|
52
|
+
lines.push({ kind: "blank", text: "" });
|
|
53
|
+
if (entry.kind === "logo") {
|
|
54
|
+
// Never wrapped: a cut row of the wordmark reads better than a broken one.
|
|
55
|
+
lines.push({ kind: "logo", text: entry.text, segments: logoSegments(entry.text) });
|
|
56
|
+
previous = entry.kind;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const marker = MARKERS[entry.kind];
|
|
60
|
+
const indent = " ".repeat(displayWidth(marker));
|
|
61
|
+
const body = wrapText(entry.text, Math.max(8, width - displayWidth(marker)));
|
|
62
|
+
for (const [i, text] of body.entries()) {
|
|
63
|
+
lines.push({ kind: entry.kind, text: (i === 0 ? marker : indent) + text });
|
|
64
|
+
}
|
|
65
|
+
previous = entry.kind;
|
|
66
|
+
}
|
|
67
|
+
return lines;
|
|
68
|
+
}
|
package/dist/usage.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type AnyEvent } from "@openshain/core";
|
|
2
|
+
export interface UsageSummary {
|
|
3
|
+
modelCalls: number;
|
|
4
|
+
toolCalls: number;
|
|
5
|
+
inputTokens: number;
|
|
6
|
+
/** The part of inputTokens a prompt cache served. */
|
|
7
|
+
cachedInputTokens: number;
|
|
8
|
+
outputTokens: number;
|
|
9
|
+
}
|
|
10
|
+
/** Totals over a work's events: calls and tokens. */
|
|
11
|
+
export declare function summarizeUsage(events: AnyEvent[]): UsageSummary;
|
|
12
|
+
export declare function formatUsage(summary: UsageSummary): string;
|
package/dist/usage.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { countToolCalls } from "@openshain/core";
|
|
2
|
+
/** Totals over a work's events: calls and tokens. */
|
|
3
|
+
export function summarizeUsage(events) {
|
|
4
|
+
const summary = {
|
|
5
|
+
modelCalls: 0,
|
|
6
|
+
toolCalls: 0,
|
|
7
|
+
inputTokens: 0,
|
|
8
|
+
cachedInputTokens: 0,
|
|
9
|
+
outputTokens: 0,
|
|
10
|
+
};
|
|
11
|
+
for (const event of events) {
|
|
12
|
+
if (event.type === "model.requested")
|
|
13
|
+
summary.modelCalls += 1;
|
|
14
|
+
if (event.type === "usage.recorded") {
|
|
15
|
+
const { payload } = event;
|
|
16
|
+
if (payload.kind === "model_inference") {
|
|
17
|
+
summary.inputTokens += payload.usage.inputTokens;
|
|
18
|
+
summary.cachedInputTokens += payload.usage.cachedInputTokens ?? 0;
|
|
19
|
+
summary.outputTokens += payload.usage.outputTokens;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
summary.toolCalls = countToolCalls(events);
|
|
24
|
+
return summary;
|
|
25
|
+
}
|
|
26
|
+
export function formatUsage(summary) {
|
|
27
|
+
if (summary.modelCalls === 0 && summary.inputTokens === 0 && summary.outputTokens === 0) {
|
|
28
|
+
return `model 呼び出し 0 回、Tool 呼び出し ${summary.toolCalls} 回。model の使用量の記録はない`;
|
|
29
|
+
}
|
|
30
|
+
const cached = summary.cachedInputTokens > 0 ? `(うちキャッシュ ${summary.cachedInputTokens})` : "";
|
|
31
|
+
return `model 呼び出し ${summary.modelCalls} 回、Tool 呼び出し ${summary.toolCalls} 回、入力 ${summary.inputTokens} トークン${cached}、出力 ${summary.outputTokens} トークン`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { CONFIG_FILE_NAME, OpenshainError } from "@openshain/core";
|
|
4
|
+
/** The nearest directory at or above `start` that holds openshain.yaml. */
|
|
5
|
+
export async function findWorkspace(start) {
|
|
6
|
+
let dir = resolve(start);
|
|
7
|
+
for (;;) {
|
|
8
|
+
try {
|
|
9
|
+
await access(join(dir, CONFIG_FILE_NAME));
|
|
10
|
+
return dir;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
const parent = dirname(dir);
|
|
14
|
+
if (parent === dir) {
|
|
15
|
+
throw new OpenshainError("config", `${CONFIG_FILE_NAME} が見つかりません。${resolve(start)} から上に向かって探しました。openshain init で作成します。`);
|
|
16
|
+
}
|
|
17
|
+
dir = parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openshain",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Reference CLI of the openshain agent harness",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openshain",
|
|
@@ -21,9 +21,11 @@
|
|
|
21
21
|
"bugs": "https://github.com/openshain/openshain/issues",
|
|
22
22
|
"type": "module",
|
|
23
23
|
"engines": {
|
|
24
|
+
"node": ">=22",
|
|
24
25
|
"bun": ">=1.3"
|
|
25
26
|
},
|
|
26
27
|
"files": [
|
|
28
|
+
"dist",
|
|
27
29
|
"src",
|
|
28
30
|
"!src/**/*.test.ts",
|
|
29
31
|
"!src/**/*.test.tsx",
|
|
@@ -31,17 +33,25 @@
|
|
|
31
33
|
"LICENSE"
|
|
32
34
|
],
|
|
33
35
|
"exports": {
|
|
34
|
-
".":
|
|
36
|
+
".": {
|
|
37
|
+
"bun": "./src/index.ts",
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"import": "./dist/index.js"
|
|
40
|
+
}
|
|
35
41
|
},
|
|
36
42
|
"bin": {
|
|
37
|
-
"openshain": "./
|
|
43
|
+
"openshain": "./dist/bin.js"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "../../node_modules/.bin/tsc -p tsconfig.build.json",
|
|
47
|
+
"prepublishOnly": "rm -rf dist && ../../node_modules/.bin/tsc -p tsconfig.build.json"
|
|
38
48
|
},
|
|
39
49
|
"dependencies": {
|
|
40
50
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
41
|
-
"@openshain/agent": "0.1
|
|
42
|
-
"@openshain/core": "0.1
|
|
43
|
-
"@openshain/mcp": "0.1
|
|
44
|
-
"@openshain/tools": "0.1
|
|
51
|
+
"@openshain/agent": "0.3.1",
|
|
52
|
+
"@openshain/core": "0.3.1",
|
|
53
|
+
"@openshain/mcp": "0.3.1",
|
|
54
|
+
"@openshain/tools": "0.3.1",
|
|
45
55
|
"ink": "7.1.1",
|
|
46
56
|
"react": "19.2.8"
|
|
47
57
|
},
|
package/src/bin.ts
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
2
|
-
import { createInterface } from "node:readline/promises";
|
|
1
|
+
#!/usr/bin/env node
|
|
3
2
|
import { parseArgs } from "node:util";
|
|
4
3
|
import { anthropicProvider, openaiCompatibleProvider } from "@openshain/agent";
|
|
5
4
|
import { isOpenshainError, type RuntimeProviders } from "@openshain/core";
|
|
6
5
|
import { standardTools } from "@openshain/tools";
|
|
7
6
|
import { init } from "./commands/init.ts";
|
|
8
7
|
import { mcp } from "./commands/mcp.ts";
|
|
9
|
-
import { run } from "./commands/run.ts";
|
|
10
8
|
import { toolsList } from "./commands/tools.ts";
|
|
11
|
-
import { workList,
|
|
9
|
+
import { workList, workShow } from "./commands/work.ts";
|
|
12
10
|
import { plain } from "./format.ts";
|
|
13
11
|
import { errorLabel } from "./labels.ts";
|
|
14
12
|
import { startTui } from "./tui/index.ts";
|
|
@@ -17,11 +15,9 @@ import { findWorkspace } from "./workspace.ts";
|
|
|
17
15
|
const USAGE = `使い方:
|
|
18
16
|
openshain 端末で対話を始める
|
|
19
17
|
openshain init openshain.yaml のひな型を書く
|
|
20
|
-
openshain run "<依頼>" 依頼を Work として進める
|
|
21
18
|
openshain tools list 使える Tool の一覧
|
|
22
19
|
openshain work list Work の一覧
|
|
23
20
|
openshain work show <id> Work の詳細
|
|
24
|
-
openshain work resume <id> 途中で止まった Work を続ける
|
|
25
21
|
openshain mcp MCP Server を stdio で起動する
|
|
26
22
|
|
|
27
23
|
--workspace <dir> 起点のディレクトリ。省略時はカレントディレクトリ
|
|
@@ -67,17 +63,6 @@ async function main(argv: string[]): Promise<number> {
|
|
|
67
63
|
case "init":
|
|
68
64
|
await init({ workspaceRoot: values.workspace ?? process.cwd(), write });
|
|
69
65
|
return 0;
|
|
70
|
-
case "run": {
|
|
71
|
-
const objective = rest.join(" ").trim();
|
|
72
|
-
if (!objective) {
|
|
73
|
-
write('依頼の文を指定してください。openshain run "今月の経理を進めて" のように。');
|
|
74
|
-
return 2;
|
|
75
|
-
}
|
|
76
|
-
const workspaceRoot = await findWorkspace(values.workspace ?? process.cwd());
|
|
77
|
-
return withTerminal((ask) =>
|
|
78
|
-
run({ workspaceRoot, providers, objective, write, ...(ask && { ask }) }),
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
66
|
case "mcp": {
|
|
82
67
|
const workspaceRoot = await findWorkspace(values.workspace ?? process.cwd());
|
|
83
68
|
await mcp({ workspaceRoot, providers });
|
|
@@ -95,7 +80,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
95
80
|
case "work": {
|
|
96
81
|
const sub = rest[0];
|
|
97
82
|
const id = rest[1] ?? "";
|
|
98
|
-
if (!(sub === "list" || (
|
|
83
|
+
if (!(sub === "list" || (sub === "show" && id))) {
|
|
99
84
|
write(USAGE);
|
|
100
85
|
return 2;
|
|
101
86
|
}
|
|
@@ -104,13 +89,8 @@ async function main(argv: string[]): Promise<number> {
|
|
|
104
89
|
await workList({ workspaceRoot, write });
|
|
105
90
|
return 0;
|
|
106
91
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return 0;
|
|
110
|
-
}
|
|
111
|
-
return withTerminal((ask) =>
|
|
112
|
-
workResume({ workspaceRoot, providers, id, write, ...(ask && { ask }) }),
|
|
113
|
-
);
|
|
92
|
+
await workShow({ workspaceRoot, id, write });
|
|
93
|
+
return 0;
|
|
114
94
|
}
|
|
115
95
|
default:
|
|
116
96
|
write(`不明なコマンド ${command}`);
|
|
@@ -119,20 +99,6 @@ async function main(argv: string[]): Promise<number> {
|
|
|
119
99
|
}
|
|
120
100
|
}
|
|
121
101
|
|
|
122
|
-
/** Runs `fn` with a way to ask the person when a terminal is there to answer; otherwise without one. */
|
|
123
|
-
async function withTerminal(
|
|
124
|
-
fn: (ask?: (question: string) => Promise<string>) => Promise<number>,
|
|
125
|
-
): Promise<number> {
|
|
126
|
-
// Without a terminal there is no one to answer; the work waits instead.
|
|
127
|
-
if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) return fn();
|
|
128
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
129
|
-
try {
|
|
130
|
-
return await fn((question) => rl.question(`${plain(question)}\n> `));
|
|
131
|
-
} finally {
|
|
132
|
-
rl.close();
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
102
|
main(process.argv.slice(2)).then(
|
|
137
103
|
(code) => process.exit(code),
|
|
138
104
|
(err: unknown) => {
|