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.
- package/LICENSE +202 -0
- package/README.md +7 -0
- package/package.json +52 -0
- package/src/bin.ts +147 -0
- package/src/commands/init.ts +144 -0
- package/src/commands/mcp.ts +24 -0
- package/src/commands/run.ts +137 -0
- package/src/commands/tools.ts +32 -0
- package/src/commands/work.ts +137 -0
- package/src/format.ts +106 -0
- package/src/index.ts +40 -0
- package/src/labels.ts +68 -0
- package/src/tui/app.tsx +223 -0
- package/src/tui/banner.ts +40 -0
- package/src/tui/controller.ts +357 -0
- package/src/tui/index.ts +51 -0
- package/src/tui/lines.ts +75 -0
- package/src/usage.ts +44 -0
- package/src/workspace.ts +23 -0
package/src/tui/lines.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { displayWidth } from "../format.ts";
|
|
2
|
+
import { logoSegments, type Segment } from "./banner.ts";
|
|
3
|
+
import type { Entry, EntryKind } from "./controller.ts";
|
|
4
|
+
|
|
5
|
+
export interface ScreenLine {
|
|
6
|
+
kind: EntryKind | "blank";
|
|
7
|
+
text: string;
|
|
8
|
+
/** Colored pieces of a logo row; the other rows are one color. */
|
|
9
|
+
segments?: Segment[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** What starts a line of each kind. The continuation lines of a wrapped entry are indented to match. */
|
|
13
|
+
const MARKERS: Record<EntryKind, string> = {
|
|
14
|
+
user: "> ",
|
|
15
|
+
assistant: "⏺ ",
|
|
16
|
+
progress: " ⎿ ",
|
|
17
|
+
notice: "! ",
|
|
18
|
+
question: "? ",
|
|
19
|
+
line: " ",
|
|
20
|
+
logo: "",
|
|
21
|
+
banner: "",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Breaks text into lines no wider than `width` display columns, counting East Asian wide characters as two. */
|
|
25
|
+
export function wrapText(text: string, width: number): string[] {
|
|
26
|
+
const out: string[] = [];
|
|
27
|
+
for (const paragraph of text.split("\n")) {
|
|
28
|
+
let line = "";
|
|
29
|
+
let used = 0;
|
|
30
|
+
for (const ch of paragraph) {
|
|
31
|
+
const w = displayWidth(ch);
|
|
32
|
+
if (used + w > width && line !== "") {
|
|
33
|
+
out.push(line);
|
|
34
|
+
line = "";
|
|
35
|
+
used = 0;
|
|
36
|
+
}
|
|
37
|
+
line += ch;
|
|
38
|
+
used += w;
|
|
39
|
+
}
|
|
40
|
+
out.push(line);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A blank row goes before an entry that starts something new: a message, a reply, a notice, a question. */
|
|
46
|
+
function startsBlock(kind: EntryKind, previous: EntryKind | undefined): boolean {
|
|
47
|
+
if (previous === undefined) return false;
|
|
48
|
+
if (kind === "logo" || kind === "banner") return false;
|
|
49
|
+
if (kind === "progress") return previous === "user";
|
|
50
|
+
if (kind === "line") return previous !== "line";
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The rows the screen shows for the entries, wrapped to the width, with markers and blank rows between blocks. */
|
|
55
|
+
export function screenLines(entries: readonly Entry[], width: number): ScreenLine[] {
|
|
56
|
+
const lines: ScreenLine[] = [];
|
|
57
|
+
let previous: EntryKind | undefined;
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
if (startsBlock(entry.kind, previous)) lines.push({ kind: "blank", text: "" });
|
|
60
|
+
if (entry.kind === "logo") {
|
|
61
|
+
// Never wrapped: a cut row of the wordmark reads better than a broken one.
|
|
62
|
+
lines.push({ kind: "logo", text: entry.text, segments: logoSegments(entry.text) });
|
|
63
|
+
previous = entry.kind;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const marker = MARKERS[entry.kind];
|
|
67
|
+
const indent = " ".repeat(displayWidth(marker));
|
|
68
|
+
const body = wrapText(entry.text, Math.max(8, width - displayWidth(marker)));
|
|
69
|
+
for (const [i, text] of body.entries()) {
|
|
70
|
+
lines.push({ kind: entry.kind, text: (i === 0 ? marker : indent) + text });
|
|
71
|
+
}
|
|
72
|
+
previous = entry.kind;
|
|
73
|
+
}
|
|
74
|
+
return lines;
|
|
75
|
+
}
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { countToolCalls } from "@openshain/agent";
|
|
2
|
+
import type { AnyEvent, Event } from "@openshain/core";
|
|
3
|
+
|
|
4
|
+
export interface UsageSummary {
|
|
5
|
+
modelCalls: number;
|
|
6
|
+
toolCalls: number;
|
|
7
|
+
inputTokens: number;
|
|
8
|
+
/** The part of inputTokens a prompt cache served. */
|
|
9
|
+
cachedInputTokens: number;
|
|
10
|
+
outputTokens: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Totals over a work's events: calls and tokens. */
|
|
14
|
+
export function summarizeUsage(events: AnyEvent[]): UsageSummary {
|
|
15
|
+
const summary: UsageSummary = {
|
|
16
|
+
modelCalls: 0,
|
|
17
|
+
toolCalls: 0,
|
|
18
|
+
inputTokens: 0,
|
|
19
|
+
cachedInputTokens: 0,
|
|
20
|
+
outputTokens: 0,
|
|
21
|
+
};
|
|
22
|
+
for (const event of events) {
|
|
23
|
+
if (event.type === "model.requested") summary.modelCalls += 1;
|
|
24
|
+
if (event.type === "usage.recorded") {
|
|
25
|
+
const { payload } = event as Event<"usage.recorded">;
|
|
26
|
+
if (payload.kind === "model_inference") {
|
|
27
|
+
summary.inputTokens += payload.usage.inputTokens;
|
|
28
|
+
summary.cachedInputTokens += payload.usage.cachedInputTokens ?? 0;
|
|
29
|
+
summary.outputTokens += payload.usage.outputTokens;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
summary.toolCalls = countToolCalls(events);
|
|
34
|
+
return summary;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatUsage(summary: UsageSummary): string {
|
|
38
|
+
if (summary.modelCalls === 0 && summary.inputTokens === 0 && summary.outputTokens === 0) {
|
|
39
|
+
return `model 呼び出し 0 回、Tool 呼び出し ${summary.toolCalls} 回。model の使用量の記録はない`;
|
|
40
|
+
}
|
|
41
|
+
const cached =
|
|
42
|
+
summary.cachedInputTokens > 0 ? `(うちキャッシュ ${summary.cachedInputTokens})` : "";
|
|
43
|
+
return `model 呼び出し ${summary.modelCalls} 回、Tool 呼び出し ${summary.toolCalls} 回、入力 ${summary.inputTokens} トークン${cached}、出力 ${summary.outputTokens} トークン`;
|
|
44
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
|
|
5
|
+
/** The nearest directory at or above `start` that holds openshain.yaml. */
|
|
6
|
+
export async function findWorkspace(start: string): Promise<string> {
|
|
7
|
+
let dir = resolve(start);
|
|
8
|
+
for (;;) {
|
|
9
|
+
try {
|
|
10
|
+
await access(join(dir, CONFIG_FILE_NAME));
|
|
11
|
+
return dir;
|
|
12
|
+
} catch {
|
|
13
|
+
const parent = dirname(dir);
|
|
14
|
+
if (parent === dir) {
|
|
15
|
+
throw new OpenshainError(
|
|
16
|
+
"config",
|
|
17
|
+
`${CONFIG_FILE_NAME} が見つかりません。${resolve(start)} から上に向かって探しました。openshain init で作れます。`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|