openshain 0.4.0 → 0.5.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/dist/bin.js CHANGED
@@ -4,6 +4,8 @@ import { anthropicProvider, openaiCompatibleProvider } from "@openshain/agent";
4
4
  import { isOpenshainError } from "@openshain/core";
5
5
  import { standardTools } from "@openshain/tools";
6
6
  import { init } from "./commands/init.js";
7
+ import { knowledgeBuild, knowledgeCheck } from "./commands/knowledge.js";
8
+ import { knowledgeAdd } from "./commands/knowledge-add.js";
7
9
  import { mcp } from "./commands/mcp.js";
8
10
  import { toolsList } from "./commands/tools.js";
9
11
  import { workList, workShow } from "./commands/work.js";
@@ -17,6 +19,9 @@ const USAGE = `使い方:
17
19
  openshain tools list 使える Tool の一覧
18
20
  openshain work list Work の一覧
19
21
  openshain work show <id> Work の詳細
22
+ openshain knowledge build knowledge/ の決まりと資料を検証して索引を作る
23
+ openshain knowledge check 同じ検証を、索引を書かずに行う(--stale で古い資料も報告)
24
+ openshain knowledge add 決まりを 1 件、質問に答えて追加する
20
25
  openshain mcp MCP Server を stdio で起動する
21
26
 
22
27
  --workspace <dir> 起点のディレクトリ。省略時はカレントディレクトリ
@@ -27,7 +32,7 @@ const providers = {
27
32
  anthropic: (model) => anthropicProvider(model),
28
33
  "openai-compatible": (model) => openaiCompatibleProvider(model),
29
34
  },
30
- tools: { standard: () => standardTools() },
35
+ tools: { standard: (workspaceRoot) => standardTools(workspaceRoot) },
31
36
  };
32
37
  async function main(argv) {
33
38
  const write = (line) => console.log(plain(line));
@@ -36,7 +41,11 @@ async function main(argv) {
36
41
  try {
37
42
  ({ values, positionals } = parseArgs({
38
43
  args: argv,
39
- options: { workspace: { type: "string" }, help: { type: "boolean", short: "h" } },
44
+ options: {
45
+ workspace: { type: "string" },
46
+ help: { type: "boolean", short: "h" },
47
+ stale: { type: "boolean" },
48
+ },
40
49
  allowPositionals: true,
41
50
  }));
42
51
  }
@@ -75,6 +84,18 @@ async function main(argv) {
75
84
  await toolsList({ workspaceRoot, providers, write });
76
85
  return 0;
77
86
  }
87
+ case "knowledge": {
88
+ const sub = rest[0];
89
+ if (!(sub === "build" || sub === "check" || sub === "add")) {
90
+ write(USAGE);
91
+ return 2;
92
+ }
93
+ const workspaceRoot = await findWorkspace(values.workspace ?? process.cwd());
94
+ if (sub === "add")
95
+ return await knowledgeAdd({ workspaceRoot, write });
96
+ const run = sub === "build" ? knowledgeBuild : knowledgeCheck;
97
+ return await run({ workspaceRoot, write, ...(values.stale === true && { stale: true }) });
98
+ }
78
99
  case "work": {
79
100
  const sub = rest[0];
80
101
  const id = rest[1] ?? "";
@@ -1,7 +1,7 @@
1
1
  import { type Language } from "@openshain/core";
2
2
  /** The company's language for the template: from the OS locale, Japanese unless the locale says otherwise. */
3
3
  export declare function detectLanguage(env: Record<string, string | undefined>): Language;
4
- export declare const configTemplate: (language: Language) => string;
4
+ export declare const configTemplate: (language: Language, timezone: string) => string;
5
5
  export declare const CONFIG_TEMPLATE: string;
6
6
  /** Registers the runtime as a project MCP server for Claude Code. `openshain` must be on PATH. */
7
7
  export declare const MCP_TEMPLATE: string;
@@ -1,6 +1,6 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { CONFIG_FILE_NAME, OpenshainError } from "@openshain/core";
3
+ import { CONFIG_FILE_NAME, hostTimezone, OpenshainError } from "@openshain/core";
4
4
  /** The company's language for the template: from the OS locale, Japanese unless the locale says otherwise. */
5
5
  export function detectLanguage(env) {
6
6
  const raw = env.LC_ALL || env.LC_MESSAGES || env.LANG || "";
@@ -9,10 +9,11 @@ export function detectLanguage(env) {
9
9
  return "ja";
10
10
  return "en";
11
11
  }
12
- export const configTemplate = (language) => `version: 1
12
+ export const configTemplate = (language, timezone) => `version: 1
13
13
  company:
14
14
  name: サンプル株式会社 # 会社名。model に伝わる
15
15
  language: ${language} # ja | en。社員エージェントの名前の言語。init が OS の locale から埋める
16
+ timezone: ${timezone} # 会社の時刻。業務日と有効日はこれで決まる。init がこの機械の設定から埋める
16
17
  principal:
17
18
  id: alice # 依頼する人の id。小文字の英数字、_ と -
18
19
  name: Alice
@@ -27,6 +28,7 @@ model:
27
28
  api_key_env: ANTHROPIC_API_KEY # API キーを入れておく環境変数の名前。サーバーを替えるなら変数名も見直す
28
29
  # base_url: http://localhost:11434/v1 # openai-compatible のとき
29
30
  # options: { effort: high } # provider にそのまま渡す
31
+ # context_tokens: 200000 # このモデルが受け取れる入力の大きさ。会話を要約する目安に使う
30
32
  tools:
31
33
  - provider: standard
32
34
  # allow: [fs_list, fs_search, fs_read, csv_read, csv_aggregate, markdown_read, fs_write, csv_write] # 省略時は全部
@@ -35,10 +37,11 @@ limits:
35
37
  max_model_calls: 30 # 超えると Work は失敗(上限到達)で止まる
36
38
  max_tool_calls: 100
37
39
  max_output_tokens: 16000 # model の 1 回の出力の上限
40
+ # compact_at_input_tokens: 150000 # ここを超えたら次のターンの前に会話を要約する。0 で要約しない
38
41
  # debug:
39
42
  # persist_raw: true # provider の生の応答を記録に残す
40
43
  `;
41
- export const CONFIG_TEMPLATE = configTemplate("ja");
44
+ export const CONFIG_TEMPLATE = configTemplate("ja", "Asia/Tokyo");
42
45
  /** Registers the runtime as a project MCP server for Claude Code. `openshain` must be on PATH. */
43
46
  export const MCP_TEMPLATE = `${JSON.stringify({ mcpServers: { openshain: { command: "openshain", args: ["mcp"] } } }, null, 2)}\n`;
44
47
  /** What an outside agent reads before working in the folder. Codex reads AGENTS.md; Claude Code reads it through CLAUDE.md. */
@@ -65,7 +68,9 @@ export const CLAUDE_TEMPLATE = "@AGENTS.md\n";
65
68
  export async function init({ workspaceRoot, write }) {
66
69
  const configPath = join(workspaceRoot, CONFIG_FILE_NAME);
67
70
  try {
68
- await writeFile(configPath, configTemplate(detectLanguage(process.env)), { flag: "wx" });
71
+ await writeFile(configPath, configTemplate(detectLanguage(process.env), hostTimezone()), {
72
+ flag: "wx",
73
+ });
69
74
  }
70
75
  catch (err) {
71
76
  const code = err.code;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Adding one rule by answering questions, so that a person does not have to learn the shape of
3
+ * the file to write one. Nothing is written until the rule holds up against everything already
4
+ * there: a rule that would break the build never reaches the folder, so adding one can only
5
+ * succeed or leave the company's knowledge exactly as it was.
6
+ */
7
+ export interface KnowledgeAddOptions {
8
+ workspaceRoot: string;
9
+ write: (line: string) => void;
10
+ /** Asks the person one question and returns what they typed. Given by the terminal. */
11
+ ask?: (question: string) => Promise<string>;
12
+ /** Whether a person is there to answer. */
13
+ interactive?: boolean;
14
+ today?: string;
15
+ }
16
+ export declare function knowledgeAdd(options: KnowledgeAddOptions): Promise<number>;
@@ -0,0 +1,91 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { checkKnowledge, KNOWLEDGE_DIR_NAME, knowledgePath, readKnowledgeFile, writeKnowledgeFile, } from "@openshain/core";
3
+ import { knowledgeBuild } from "./knowledge.js";
4
+ export async function knowledgeAdd(options) {
5
+ const { write } = options;
6
+ const interactive = options.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
7
+ if (!interactive) {
8
+ write("openshain knowledge add は端末で使います。決まりを直接書くなら knowledge/rules/ です。");
9
+ return 2;
10
+ }
11
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
12
+ const ask = options.ask ?? ((question) => readline.question(`${question}\n> `));
13
+ try {
14
+ return await run({ ...options, ask }, write);
15
+ }
16
+ finally {
17
+ readline.close();
18
+ }
19
+ }
20
+ async function run(options, write) {
21
+ const { workspaceRoot, ask } = options;
22
+ const today = options.today ?? new Date().toISOString().slice(0, 10);
23
+ const existing = await checkKnowledge(workspaceRoot);
24
+ if (existing.sources.length === 0) {
25
+ write(`根拠になる資料がまだありません。${KNOWLEDGE_DIR_NAME}/sources/ に 1 件置いてから実行してください。`);
26
+ return 1;
27
+ }
28
+ write("会社の決まりを 1 件追加します。答えたくない項目は空のまま Enter で戻れます。");
29
+ const statement = (await ask("どんな決まりですか。1 文で書いてください")).trim();
30
+ if (statement === "") {
31
+ write("何も書かれなかったので、やめました。");
32
+ return 1;
33
+ }
34
+ const id = (await ask("この決まりの id(例 expenses.receipt-required)")).trim();
35
+ const from = (await ask(`いつから有効ですか(YYYY-MM-DD。空なら ${today})`)).trim() || today;
36
+ const to = (await ask("いつまでですか(期限がなければ空のまま)")).trim();
37
+ write("根拠にする資料を選んでください。");
38
+ for (const source of existing.sources)
39
+ write(` ${source.id} ${source.title}`);
40
+ const sourceId = (await ask("資料の id")).trim();
41
+ const section = (await ask("その資料のどの節ですか(なければ空のまま)")).trim();
42
+ const aliases = (await ask("他にどう言い換えますか(読点で区切ります。なければ空のまま)"))
43
+ .split(/[,、]/)
44
+ .map((word) => word.trim())
45
+ .filter(Boolean);
46
+ const expertise = (await ask("資格者の領域に関わりますか(none、tax、legal、labor など。空なら none)")).trim() ||
47
+ "none";
48
+ const rule = {
49
+ id,
50
+ statement,
51
+ ...(aliases.length > 0 && { aliases }),
52
+ effective_from: from,
53
+ effective_to: to === "" ? null : to,
54
+ expertise,
55
+ source: { id: sourceId, ...(section !== "" && { section }) },
56
+ };
57
+ // The file it would live in, named after what the id is about.
58
+ const parts = ["rules", `${(id.split(".")[0] || "rules").replace(/[^a-z0-9-]/g, "")}.yaml`];
59
+ const file = knowledgePath(parts);
60
+ const checked = await checkKnowledge(workspaceRoot, {
61
+ adding: [{ ...rule, file }],
62
+ });
63
+ if (checked.problems.length > 0) {
64
+ for (const problem of checked.problems)
65
+ write(problem);
66
+ write("この決まりは追加していません。会社の決まりはそのままです。");
67
+ return 1;
68
+ }
69
+ const before = await readKnowledgeFile(workspaceRoot, parts);
70
+ await writeKnowledgeFile(workspaceRoot, parts, appended(before, rule));
71
+ write(`${file} に ${id} を書きました。`);
72
+ return knowledgeBuild({ workspaceRoot, write });
73
+ }
74
+ /** The rule as a person would have written it, added to the file or starting one. */
75
+ function appended(before, rule) {
76
+ const lines = [
77
+ ` - id: ${rule.id}`,
78
+ ` statement: ${quoted(rule.statement)}`,
79
+ ...(rule.aliases ? [` aliases: [${rule.aliases.map(quoted).join(", ")}]`] : []),
80
+ ` effective_from: ${rule.effective_from}`,
81
+ ` effective_to: ${rule.effective_to === null ? "null" : rule.effective_to}`,
82
+ ` expertise: ${rule.expertise}`,
83
+ ` source: { id: ${rule.source.id}${rule.source.section ? `, section: ${quoted(rule.source.section)}` : ""} }`,
84
+ ];
85
+ const head = before?.trimEnd() ?? "version: 1\nrules:";
86
+ return `${head}\n${lines.join("\n")}\n`;
87
+ }
88
+ /** YAML that means the string and nothing else, whatever is in it. */
89
+ function quoted(text) {
90
+ return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
91
+ }
@@ -0,0 +1,15 @@
1
+ export interface KnowledgeOptions {
2
+ workspaceRoot: string;
3
+ write: (line: string) => void;
4
+ /** Also say which sources have not been looked at for a year. Never changes the outcome. */
5
+ stale?: boolean;
6
+ now?: Date;
7
+ }
8
+ /**
9
+ * Checks what a person wrote and, when nothing is wrong, writes the index. Every problem is
10
+ * printed, not only the first: a person fixing a set of files should need one pass, not one run
11
+ * per mistake. When anything is wrong, `knowledge/build/` is left exactly as it was.
12
+ */
13
+ export declare function knowledgeBuild(options: KnowledgeOptions): Promise<number>;
14
+ /** The same checks without writing anything, for a person or for CI. */
15
+ export declare function knowledgeCheck(options: KnowledgeOptions): Promise<number>;
@@ -0,0 +1,62 @@
1
+ import { buildIndex, checkKnowledge, hashKnowledgeInput, hasKnowledge, KNOWLEDGE_DIR_NAME, writeIndex, } from "@openshain/core";
2
+ /** A year is how long a company's source may sit before it is worth looking at again. */
3
+ const STALE_DAYS = 365;
4
+ /**
5
+ * Checks what a person wrote and, when nothing is wrong, writes the index. Every problem is
6
+ * printed, not only the first: a person fixing a set of files should need one pass, not one run
7
+ * per mistake. When anything is wrong, `knowledge/build/` is left exactly as it was.
8
+ */
9
+ export async function knowledgeBuild(options) {
10
+ const { workspaceRoot, write } = options;
11
+ if (!(await hasKnowledge(workspaceRoot))) {
12
+ write(`${KNOWLEDGE_DIR_NAME}/ がありません。会社の決まりは ${KNOWLEDGE_DIR_NAME}/rules/、根拠の資料は ${KNOWLEDGE_DIR_NAME}/sources/ に置きます。`);
13
+ return 1;
14
+ }
15
+ const checked = await checkKnowledge(workspaceRoot);
16
+ if (checked.problems.length > 0) {
17
+ for (const problem of checked.problems)
18
+ write(problem);
19
+ write(`${checked.problems.length} 件の問題があります。索引は作りませんでした。`);
20
+ return 1;
21
+ }
22
+ if (options.stale)
23
+ for (const line of staleLines(checked.sources, options.now))
24
+ write(line);
25
+ const manifest = await writeIndex(workspaceRoot, buildIndex(checked), {
26
+ hash: await hashKnowledgeInput(workspaceRoot),
27
+ rules: checked.rules.length,
28
+ sources: checked.sources.length,
29
+ });
30
+ write(`決まり ${manifest.rules} 件、資料 ${manifest.sources} 件から ${manifest.units} 件の索引を作りました。`);
31
+ write("対話を開いている場合は、いったん閉じて開き直すと社員エージェントが引けるようになります。");
32
+ return 0;
33
+ }
34
+ /** The same checks without writing anything, for a person or for CI. */
35
+ export async function knowledgeCheck(options) {
36
+ const { workspaceRoot, write } = options;
37
+ if (!(await hasKnowledge(workspaceRoot))) {
38
+ write(`${KNOWLEDGE_DIR_NAME}/ がありません。`);
39
+ return 1;
40
+ }
41
+ const checked = await checkKnowledge(workspaceRoot);
42
+ for (const problem of checked.problems)
43
+ write(problem);
44
+ if (options.stale)
45
+ for (const line of staleLines(checked.sources, options.now))
46
+ write(line);
47
+ if (checked.problems.length > 0) {
48
+ write(`${checked.problems.length} 件の問題があります。`);
49
+ return 1;
50
+ }
51
+ write(`決まり ${checked.rules.length} 件、資料 ${checked.sources.length} 件。問題はありません。`);
52
+ return 0;
53
+ }
54
+ /** Sources nobody has looked at for a year. A warning, never a reason to fail. */
55
+ function staleLines(sources, now = new Date()) {
56
+ const limit = new Date(now);
57
+ limit.setUTCDate(limit.getUTCDate() - STALE_DAYS);
58
+ const cutoff = limit.toISOString().slice(0, 10);
59
+ return sources
60
+ .filter((source) => source.retrieved_at < cutoff)
61
+ .map((source) => `${source.file}: ${source.id} を確かめたのは ${source.retrieved_at} です。出どころが変わっていないか確認してください。`);
62
+ }
package/dist/labels.js CHANGED
@@ -32,6 +32,7 @@ export const ERROR_LABELS = {
32
32
  auth: "認証の失敗",
33
33
  network: "接続の失敗",
34
34
  rate_limit: "呼び出し上限",
35
+ too_large: "入力が大きすぎる",
35
36
  invalid_response: "解釈できない model の応答",
36
37
  config: "設定の問題",
37
38
  corrupt_log: "壊れた Work の記録",
package/dist/preview.js CHANGED
@@ -1,5 +1,4 @@
1
- import { readFile } from "node:fs/promises";
2
- import { resolveWorkspacePath } from "@openshain/core";
1
+ import { readWorkspaceTextIfAny, resolveWorkspacePath } from "@openshain/core";
3
2
  import { csvText } from "@openshain/tools";
4
3
  /** How much of a change the screen shows before it says the rest is cut. */
5
4
  const MAX_LINES = 24;
@@ -30,9 +29,8 @@ export async function previewCall(workspaceRoot, call) {
30
29
  // The same guard the tools run under: a path outside the workspace, a reserved one or a
31
30
  // symlink that leads out is refused here too, so the screen never shows what the call cannot
32
31
  // touch. The model chooses this path; the person is about to read what it says.
33
- let resolved;
34
32
  try {
35
- resolved = await resolveWorkspacePath(workspaceRoot, path);
33
+ await resolveWorkspacePath(workspaceRoot, path);
36
34
  }
37
35
  catch (err) {
38
36
  return [
@@ -42,7 +40,7 @@ export async function previewCall(workspaceRoot, call) {
42
40
  },
43
41
  ];
44
42
  }
45
- const before = await readFile(resolved, "utf8").catch(() => undefined);
43
+ const before = await readWorkspaceTextIfAny(workspaceRoot, path);
46
44
  if (before === undefined) {
47
45
  const lines = content.split("\n");
48
46
  return cap([
@@ -1,4 +1,4 @@
1
- import { type ApprovalChoice } from "@openshain/agent";
1
+ import { type ApprovalChoice, type CompactionOutcome } from "@openshain/agent";
2
2
  import { type RuntimeProviders, type WorkId, WorkStore } from "@openshain/core";
3
3
  import { statusLabel } from "../labels.ts";
4
4
  import { type PreviewLine } from "../preview.ts";
@@ -70,6 +70,11 @@ export interface ControllerOptions {
70
70
  workspaceRoot: string;
71
71
  providers: RuntimeProviders;
72
72
  }
73
+ /**
74
+ * What the screen says when the conversation was summarized. The person cannot read the summary
75
+ * itself, so the line carries what they told the agent: that is the part they can check.
76
+ */
77
+ export declare function compactionLine(outcome: CompactionOutcome): string;
73
78
  /**
74
79
  * The state behind the screen: a session, the works it starts, and the lines to show. The
75
80
  * conversation reaches the runtime only as an MCP client of the workspace's own server, the way
@@ -8,6 +8,51 @@ import { statusLabel } from "../labels.js";
8
8
  import { previewCall } from "../preview.js";
9
9
  import { progressLine, report } from "../report.js";
10
10
  import { LOGO_ROWS, VERSION } from "./banner.js";
11
+ /**
12
+ * What the screen says when the conversation was summarized. The person cannot read the summary
13
+ * itself, so the line carries what they told the agent: that is the part they can check.
14
+ */
15
+ export function compactionLine(outcome) {
16
+ if (!outcome.done) {
17
+ switch (outcome.reason) {
18
+ case "nothing_to_compact":
19
+ return "まだ要約するところがありません。";
20
+ case "no_smaller":
21
+ return "この会話はこれ以上要約できません。いったん終えて、新しく始めるほうが確かです。";
22
+ case "empty":
23
+ return "要約が空だったので、会話はそのままです。";
24
+ case "secret":
25
+ return "要約に鍵らしき文字列が入ったので、記録しませんでした。会話はそのままです。";
26
+ default:
27
+ return `要約に失敗したので、会話はそのままです。${outcome.detail ?? ""}`.trim();
28
+ }
29
+ }
30
+ const assumed = heading(outcome.summary, "人が伝えた前提");
31
+ const kept = assumed ? `。人が伝えた前提: ${assumed}` : "";
32
+ return `会話を要約しました(${outcome.covered} 件を 1 件に)${kept}`;
33
+ }
34
+ /** What one heading of a summary says, in one line and at most this long. */
35
+ function heading(summary, name) {
36
+ const lines = summary.split("\n");
37
+ const at = lines.findIndex((line) => line.includes(name));
38
+ if (at < 0)
39
+ return undefined;
40
+ const said = [];
41
+ for (const line of lines.slice(at + 1)) {
42
+ const text = line.replace(/^[#\s*-]+/, "").trim();
43
+ if (text === "")
44
+ continue;
45
+ if (/^#/.test(line) || /^\*\*/.test(line))
46
+ break;
47
+ said.push(text);
48
+ if (said.join("、").length > 120)
49
+ break;
50
+ }
51
+ const all = said.join("、");
52
+ if (all === "" || all === "なし")
53
+ return undefined;
54
+ return all.length > 120 ? `${all.slice(0, 120)}…` : all;
55
+ }
11
56
  const HELP = [
12
57
  "/work list Work の一覧",
13
58
  "/work show <id> Work の詳細",
@@ -15,6 +60,7 @@ const HELP = [
15
60
  "/approvals 承認待ちの一覧",
16
61
  "/approve <id> 承認して実行する。/reject <id> [理由] で拒否する",
17
62
  "/review <id> approve|reject 資格者の判断を記録する。名前と本文を順に聞く",
63
+ "/compact 会話を要約して短くする。長い会話は自動でも要約される",
18
64
  "/tools 使える Tool",
19
65
  "/quit 終わる",
20
66
  "↑ ↓ 前に送った行を入力欄に呼び戻す。いちばん下は新しい入力",
@@ -317,6 +363,10 @@ export async function createController(options) {
317
363
  await close();
318
364
  else if (name === "tools")
319
365
  await capture((write) => toolsList({ ...options, write }));
366
+ else if (name === "compact") {
367
+ const outcome = await session.compact();
368
+ push(outcome.done ? "progress" : "notice", compactionLine(outcome));
369
+ }
320
370
  else if (name === "work" && sub === "list")
321
371
  await capture((write) => workList({ workspaceRoot: options.workspaceRoot, write }));
322
372
  else if (name === "work" && (sub === "show" || sub === "resume") && !args[1])
@@ -464,6 +514,9 @@ export async function createController(options) {
464
514
  await stoppable(async (signal) => {
465
515
  try {
466
516
  const result = await session.turn(text, { signal });
517
+ if (result.compacted) {
518
+ push(result.compacted.done ? "progress" : "notice", compactionLine(result.compacted));
519
+ }
467
520
  // What the work recorded is the agent's own writing, so it stands in when the turn
468
521
  // ends with nothing said. Without this the person is left with a work that finished
469
522
  // and no answer, which is what a model that skips its summary leaves behind.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openshain",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Reference CLI of the openshain agent harness",
5
5
  "keywords": [
6
6
  "openshain",
@@ -49,10 +49,10 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "@modelcontextprotocol/sdk": "1.30.0",
52
- "@openshain/agent": "0.4.0",
53
- "@openshain/core": "0.4.0",
54
- "@openshain/mcp": "0.4.0",
55
- "@openshain/tools": "0.4.0",
52
+ "@openshain/agent": "0.5.0",
53
+ "@openshain/core": "0.5.0",
54
+ "@openshain/mcp": "0.5.0",
55
+ "@openshain/tools": "0.5.0",
56
56
  "ink": "7.1.1",
57
57
  "marked": "18.0.12",
58
58
  "react": "19.2.8"
package/src/bin.ts CHANGED
@@ -4,6 +4,8 @@ import { anthropicProvider, openaiCompatibleProvider } from "@openshain/agent";
4
4
  import { isOpenshainError, type RuntimeProviders } from "@openshain/core";
5
5
  import { standardTools } from "@openshain/tools";
6
6
  import { init } from "./commands/init.ts";
7
+ import { knowledgeBuild, knowledgeCheck } from "./commands/knowledge.ts";
8
+ import { knowledgeAdd } from "./commands/knowledge-add.ts";
7
9
  import { mcp } from "./commands/mcp.ts";
8
10
  import { toolsList } from "./commands/tools.ts";
9
11
  import { workList, workShow } from "./commands/work.ts";
@@ -18,6 +20,9 @@ const USAGE = `使い方:
18
20
  openshain tools list 使える Tool の一覧
19
21
  openshain work list Work の一覧
20
22
  openshain work show <id> Work の詳細
23
+ openshain knowledge build knowledge/ の決まりと資料を検証して索引を作る
24
+ openshain knowledge check 同じ検証を、索引を書かずに行う(--stale で古い資料も報告)
25
+ openshain knowledge add 決まりを 1 件、質問に答えて追加する
21
26
  openshain mcp MCP Server を stdio で起動する
22
27
 
23
28
  --workspace <dir> 起点のディレクトリ。省略時はカレントディレクトリ
@@ -29,17 +34,21 @@ const providers: RuntimeProviders = {
29
34
  anthropic: (model) => anthropicProvider(model),
30
35
  "openai-compatible": (model) => openaiCompatibleProvider(model),
31
36
  },
32
- tools: { standard: () => standardTools() },
37
+ tools: { standard: (workspaceRoot) => standardTools(workspaceRoot) },
33
38
  };
34
39
 
35
40
  async function main(argv: string[]): Promise<number> {
36
41
  const write = (line: string) => console.log(plain(line));
37
- let values: { workspace?: string; help?: boolean };
42
+ let values: { workspace?: string; help?: boolean; stale?: boolean };
38
43
  let positionals: string[];
39
44
  try {
40
45
  ({ values, positionals } = parseArgs({
41
46
  args: argv,
42
- options: { workspace: { type: "string" }, help: { type: "boolean", short: "h" } },
47
+ options: {
48
+ workspace: { type: "string" },
49
+ help: { type: "boolean", short: "h" },
50
+ stale: { type: "boolean" },
51
+ },
43
52
  allowPositionals: true,
44
53
  }));
45
54
  } catch (err) {
@@ -77,6 +86,17 @@ async function main(argv: string[]): Promise<number> {
77
86
  await toolsList({ workspaceRoot, providers, write });
78
87
  return 0;
79
88
  }
89
+ case "knowledge": {
90
+ const sub = rest[0];
91
+ if (!(sub === "build" || sub === "check" || sub === "add")) {
92
+ write(USAGE);
93
+ return 2;
94
+ }
95
+ const workspaceRoot = await findWorkspace(values.workspace ?? process.cwd());
96
+ if (sub === "add") return await knowledgeAdd({ workspaceRoot, write });
97
+ const run = sub === "build" ? knowledgeBuild : knowledgeCheck;
98
+ return await run({ workspaceRoot, write, ...(values.stale === true && { stale: true }) });
99
+ }
80
100
  case "work": {
81
101
  const sub = rest[0];
82
102
  const id = rest[1] ?? "";
@@ -1,6 +1,6 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { CONFIG_FILE_NAME, type Language, OpenshainError } from "@openshain/core";
3
+ import { CONFIG_FILE_NAME, hostTimezone, type Language, OpenshainError } from "@openshain/core";
4
4
 
5
5
  /** The company's language for the template: from the OS locale, Japanese unless the locale says otherwise. */
6
6
  export function detectLanguage(env: Record<string, string | undefined>): Language {
@@ -10,10 +10,11 @@ export function detectLanguage(env: Record<string, string | undefined>): Languag
10
10
  return "en";
11
11
  }
12
12
 
13
- export const configTemplate = (language: Language) => `version: 1
13
+ export const configTemplate = (language: Language, timezone: string) => `version: 1
14
14
  company:
15
15
  name: サンプル株式会社 # 会社名。model に伝わる
16
16
  language: ${language} # ja | en。社員エージェントの名前の言語。init が OS の locale から埋める
17
+ timezone: ${timezone} # 会社の時刻。業務日と有効日はこれで決まる。init がこの機械の設定から埋める
17
18
  principal:
18
19
  id: alice # 依頼する人の id。小文字の英数字、_ と -
19
20
  name: Alice
@@ -28,6 +29,7 @@ model:
28
29
  api_key_env: ANTHROPIC_API_KEY # API キーを入れておく環境変数の名前。サーバーを替えるなら変数名も見直す
29
30
  # base_url: http://localhost:11434/v1 # openai-compatible のとき
30
31
  # options: { effort: high } # provider にそのまま渡す
32
+ # context_tokens: 200000 # このモデルが受け取れる入力の大きさ。会話を要約する目安に使う
31
33
  tools:
32
34
  - provider: standard
33
35
  # allow: [fs_list, fs_search, fs_read, csv_read, csv_aggregate, markdown_read, fs_write, csv_write] # 省略時は全部
@@ -36,11 +38,12 @@ limits:
36
38
  max_model_calls: 30 # 超えると Work は失敗(上限到達)で止まる
37
39
  max_tool_calls: 100
38
40
  max_output_tokens: 16000 # model の 1 回の出力の上限
41
+ # compact_at_input_tokens: 150000 # ここを超えたら次のターンの前に会話を要約する。0 で要約しない
39
42
  # debug:
40
43
  # persist_raw: true # provider の生の応答を記録に残す
41
44
  `;
42
45
 
43
- export const CONFIG_TEMPLATE = configTemplate("ja");
46
+ export const CONFIG_TEMPLATE = configTemplate("ja", "Asia/Tokyo");
44
47
 
45
48
  /** Registers the runtime as a project MCP server for Claude Code. `openshain` must be on PATH. */
46
49
  export const MCP_TEMPLATE = `${JSON.stringify(
@@ -80,7 +83,9 @@ export interface InitOptions {
80
83
  export async function init({ workspaceRoot, write }: InitOptions): Promise<void> {
81
84
  const configPath = join(workspaceRoot, CONFIG_FILE_NAME);
82
85
  try {
83
- await writeFile(configPath, configTemplate(detectLanguage(process.env)), { flag: "wx" });
86
+ await writeFile(configPath, configTemplate(detectLanguage(process.env), hostTimezone()), {
87
+ flag: "wx",
88
+ });
84
89
  } catch (err) {
85
90
  const code = (err as NodeJS.ErrnoException).code;
86
91
  if (code === "EEXIST") {
@@ -0,0 +1,128 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import {
3
+ checkKnowledge,
4
+ KNOWLEDGE_DIR_NAME,
5
+ type KnowledgeRule,
6
+ knowledgePath,
7
+ type LoadedKnowledgeRule,
8
+ readKnowledgeFile,
9
+ writeKnowledgeFile,
10
+ } from "@openshain/core";
11
+ import { knowledgeBuild } from "./knowledge.ts";
12
+
13
+ /**
14
+ * Adding one rule by answering questions, so that a person does not have to learn the shape of
15
+ * the file to write one. Nothing is written until the rule holds up against everything already
16
+ * there: a rule that would break the build never reaches the folder, so adding one can only
17
+ * succeed or leave the company's knowledge exactly as it was.
18
+ */
19
+
20
+ export interface KnowledgeAddOptions {
21
+ workspaceRoot: string;
22
+ write: (line: string) => void;
23
+ /** Asks the person one question and returns what they typed. Given by the terminal. */
24
+ ask?: (question: string) => Promise<string>;
25
+ /** Whether a person is there to answer. */
26
+ interactive?: boolean;
27
+ today?: string;
28
+ }
29
+
30
+ export async function knowledgeAdd(options: KnowledgeAddOptions): Promise<number> {
31
+ const { write } = options;
32
+ const interactive = options.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
33
+ if (!interactive) {
34
+ write("openshain knowledge add は端末で使います。決まりを直接書くなら knowledge/rules/ です。");
35
+ return 2;
36
+ }
37
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
38
+ const ask = options.ask ?? ((question: string) => readline.question(`${question}\n> `));
39
+ try {
40
+ return await run({ ...options, ask }, write);
41
+ } finally {
42
+ readline.close();
43
+ }
44
+ }
45
+
46
+ async function run(
47
+ options: KnowledgeAddOptions & { ask: (question: string) => Promise<string> },
48
+ write: (line: string) => void,
49
+ ): Promise<number> {
50
+ const { workspaceRoot, ask } = options;
51
+ const today = options.today ?? new Date().toISOString().slice(0, 10);
52
+ const existing = await checkKnowledge(workspaceRoot);
53
+ if (existing.sources.length === 0) {
54
+ write(
55
+ `根拠になる資料がまだありません。${KNOWLEDGE_DIR_NAME}/sources/ に 1 件置いてから実行してください。`,
56
+ );
57
+ return 1;
58
+ }
59
+
60
+ write("会社の決まりを 1 件追加します。答えたくない項目は空のまま Enter で戻れます。");
61
+ const statement = (await ask("どんな決まりですか。1 文で書いてください")).trim();
62
+ if (statement === "") {
63
+ write("何も書かれなかったので、やめました。");
64
+ return 1;
65
+ }
66
+ const id = (await ask("この決まりの id(例 expenses.receipt-required)")).trim();
67
+ const from = (await ask(`いつから有効ですか(YYYY-MM-DD。空なら ${today})`)).trim() || today;
68
+ const to = (await ask("いつまでですか(期限がなければ空のまま)")).trim();
69
+
70
+ write("根拠にする資料を選んでください。");
71
+ for (const source of existing.sources) write(` ${source.id} ${source.title}`);
72
+ const sourceId = (await ask("資料の id")).trim();
73
+ const section = (await ask("その資料のどの節ですか(なければ空のまま)")).trim();
74
+ const aliases = (await ask("他にどう言い換えますか(読点で区切ります。なければ空のまま)"))
75
+ .split(/[,、]/)
76
+ .map((word) => word.trim())
77
+ .filter(Boolean);
78
+ const expertise =
79
+ (await ask("資格者の領域に関わりますか(none、tax、legal、labor など。空なら none)")).trim() ||
80
+ "none";
81
+
82
+ const rule = {
83
+ id,
84
+ statement,
85
+ ...(aliases.length > 0 && { aliases }),
86
+ effective_from: from,
87
+ effective_to: to === "" ? null : to,
88
+ expertise,
89
+ source: { id: sourceId, ...(section !== "" && { section }) },
90
+ } as KnowledgeRule;
91
+
92
+ // The file it would live in, named after what the id is about.
93
+ const parts = ["rules", `${(id.split(".")[0] || "rules").replace(/[^a-z0-9-]/g, "")}.yaml`];
94
+ const file = knowledgePath(parts);
95
+ const checked = await checkKnowledge(workspaceRoot, {
96
+ adding: [{ ...rule, file } as LoadedKnowledgeRule],
97
+ });
98
+ if (checked.problems.length > 0) {
99
+ for (const problem of checked.problems) write(problem);
100
+ write("この決まりは追加していません。会社の決まりはそのままです。");
101
+ return 1;
102
+ }
103
+
104
+ const before = await readKnowledgeFile(workspaceRoot, parts);
105
+ await writeKnowledgeFile(workspaceRoot, parts, appended(before, rule));
106
+ write(`${file} に ${id} を書きました。`);
107
+ return knowledgeBuild({ workspaceRoot, write });
108
+ }
109
+
110
+ /** The rule as a person would have written it, added to the file or starting one. */
111
+ function appended(before: string | undefined, rule: KnowledgeRule): string {
112
+ const lines = [
113
+ ` - id: ${rule.id}`,
114
+ ` statement: ${quoted(rule.statement)}`,
115
+ ...(rule.aliases ? [` aliases: [${rule.aliases.map(quoted).join(", ")}]`] : []),
116
+ ` effective_from: ${rule.effective_from}`,
117
+ ` effective_to: ${rule.effective_to === null ? "null" : rule.effective_to}`,
118
+ ` expertise: ${rule.expertise}`,
119
+ ` source: { id: ${rule.source.id}${rule.source.section ? `, section: ${quoted(rule.source.section)}` : ""} }`,
120
+ ];
121
+ const head = before?.trimEnd() ?? "version: 1\nrules:";
122
+ return `${head}\n${lines.join("\n")}\n`;
123
+ }
124
+
125
+ /** YAML that means the string and nothing else, whatever is in it. */
126
+ function quoted(text: string): string {
127
+ return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
128
+ }
@@ -0,0 +1,84 @@
1
+ import {
2
+ buildIndex,
3
+ checkKnowledge,
4
+ hashKnowledgeInput,
5
+ hasKnowledge,
6
+ KNOWLEDGE_DIR_NAME,
7
+ type KnowledgeSource,
8
+ writeIndex,
9
+ } from "@openshain/core";
10
+
11
+ export interface KnowledgeOptions {
12
+ workspaceRoot: string;
13
+ write: (line: string) => void;
14
+ /** Also say which sources have not been looked at for a year. Never changes the outcome. */
15
+ stale?: boolean;
16
+ now?: Date;
17
+ }
18
+
19
+ /** A year is how long a company's source may sit before it is worth looking at again. */
20
+ const STALE_DAYS = 365;
21
+
22
+ /**
23
+ * Checks what a person wrote and, when nothing is wrong, writes the index. Every problem is
24
+ * printed, not only the first: a person fixing a set of files should need one pass, not one run
25
+ * per mistake. When anything is wrong, `knowledge/build/` is left exactly as it was.
26
+ */
27
+ export async function knowledgeBuild(options: KnowledgeOptions): Promise<number> {
28
+ const { workspaceRoot, write } = options;
29
+ if (!(await hasKnowledge(workspaceRoot))) {
30
+ write(
31
+ `${KNOWLEDGE_DIR_NAME}/ がありません。会社の決まりは ${KNOWLEDGE_DIR_NAME}/rules/、根拠の資料は ${KNOWLEDGE_DIR_NAME}/sources/ に置きます。`,
32
+ );
33
+ return 1;
34
+ }
35
+ const checked = await checkKnowledge(workspaceRoot);
36
+ if (checked.problems.length > 0) {
37
+ for (const problem of checked.problems) write(problem);
38
+ write(`${checked.problems.length} 件の問題があります。索引は作りませんでした。`);
39
+ return 1;
40
+ }
41
+ if (options.stale) for (const line of staleLines(checked.sources, options.now)) write(line);
42
+
43
+ const manifest = await writeIndex(workspaceRoot, buildIndex(checked), {
44
+ hash: await hashKnowledgeInput(workspaceRoot),
45
+ rules: checked.rules.length,
46
+ sources: checked.sources.length,
47
+ });
48
+ write(
49
+ `決まり ${manifest.rules} 件、資料 ${manifest.sources} 件から ${manifest.units} 件の索引を作りました。`,
50
+ );
51
+ write("対話を開いている場合は、いったん閉じて開き直すと社員エージェントが引けるようになります。");
52
+ return 0;
53
+ }
54
+
55
+ /** The same checks without writing anything, for a person or for CI. */
56
+ export async function knowledgeCheck(options: KnowledgeOptions): Promise<number> {
57
+ const { workspaceRoot, write } = options;
58
+ if (!(await hasKnowledge(workspaceRoot))) {
59
+ write(`${KNOWLEDGE_DIR_NAME}/ がありません。`);
60
+ return 1;
61
+ }
62
+ const checked = await checkKnowledge(workspaceRoot);
63
+ for (const problem of checked.problems) write(problem);
64
+ if (options.stale) for (const line of staleLines(checked.sources, options.now)) write(line);
65
+ if (checked.problems.length > 0) {
66
+ write(`${checked.problems.length} 件の問題があります。`);
67
+ return 1;
68
+ }
69
+ write(`決まり ${checked.rules.length} 件、資料 ${checked.sources.length} 件。問題はありません。`);
70
+ return 0;
71
+ }
72
+
73
+ /** Sources nobody has looked at for a year. A warning, never a reason to fail. */
74
+ function staleLines(sources: KnowledgeSource[], now = new Date()): string[] {
75
+ const limit = new Date(now);
76
+ limit.setUTCDate(limit.getUTCDate() - STALE_DAYS);
77
+ const cutoff = limit.toISOString().slice(0, 10);
78
+ return sources
79
+ .filter((source) => source.retrieved_at < cutoff)
80
+ .map(
81
+ (source) =>
82
+ `${source.file}: ${source.id} を確かめたのは ${source.retrieved_at} です。出どころが変わっていないか確認してください。`,
83
+ );
84
+ }
package/src/labels.ts CHANGED
@@ -37,6 +37,7 @@ export const ERROR_LABELS: Record<ErrorCode, string> = {
37
37
  auth: "認証の失敗",
38
38
  network: "接続の失敗",
39
39
  rate_limit: "呼び出し上限",
40
+ too_large: "入力が大きすぎる",
40
41
  invalid_response: "解釈できない model の応答",
41
42
  config: "設定の問題",
42
43
  corrupt_log: "壊れた Work の記録",
package/src/preview.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { readFile } from "node:fs/promises";
2
- import { resolveWorkspacePath } from "@openshain/core";
1
+ import { readWorkspaceTextIfAny, resolveWorkspacePath } from "@openshain/core";
3
2
  import { csvText } from "@openshain/tools";
4
3
 
5
4
  /** How much of a change the screen shows before it says the rest is cut. */
@@ -49,9 +48,8 @@ export async function previewCall(
49
48
  // The same guard the tools run under: a path outside the workspace, a reserved one or a
50
49
  // symlink that leads out is refused here too, so the screen never shows what the call cannot
51
50
  // touch. The model chooses this path; the person is about to read what it says.
52
- let resolved: string;
53
51
  try {
54
- resolved = await resolveWorkspacePath(workspaceRoot, path);
52
+ await resolveWorkspacePath(workspaceRoot, path);
55
53
  } catch (err) {
56
54
  return [
57
55
  {
@@ -60,7 +58,7 @@ export async function previewCall(
60
58
  },
61
59
  ];
62
60
  }
63
- const before = await readFile(resolved, "utf8").catch(() => undefined);
61
+ const before = await readWorkspaceTextIfAny(workspaceRoot, path);
64
62
  if (before === undefined) {
65
63
  const lines = content.split("\n");
66
64
  return cap([
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type ApprovalAnswer,
3
3
  type ApprovalChoice,
4
+ type CompactionOutcome,
4
5
  connectInMemory,
5
6
  createSession,
6
7
  type HeldApproval,
@@ -95,6 +96,48 @@ export interface ControllerOptions {
95
96
  providers: RuntimeProviders;
96
97
  }
97
98
 
99
+ /**
100
+ * What the screen says when the conversation was summarized. The person cannot read the summary
101
+ * itself, so the line carries what they told the agent: that is the part they can check.
102
+ */
103
+ export function compactionLine(outcome: CompactionOutcome): string {
104
+ if (!outcome.done) {
105
+ switch (outcome.reason) {
106
+ case "nothing_to_compact":
107
+ return "まだ要約するところがありません。";
108
+ case "no_smaller":
109
+ return "この会話はこれ以上要約できません。いったん終えて、新しく始めるほうが確かです。";
110
+ case "empty":
111
+ return "要約が空だったので、会話はそのままです。";
112
+ case "secret":
113
+ return "要約に鍵らしき文字列が入ったので、記録しませんでした。会話はそのままです。";
114
+ default:
115
+ return `要約に失敗したので、会話はそのままです。${outcome.detail ?? ""}`.trim();
116
+ }
117
+ }
118
+ const assumed = heading(outcome.summary, "人が伝えた前提");
119
+ const kept = assumed ? `。人が伝えた前提: ${assumed}` : "";
120
+ return `会話を要約しました(${outcome.covered} 件を 1 件に)${kept}`;
121
+ }
122
+
123
+ /** What one heading of a summary says, in one line and at most this long. */
124
+ function heading(summary: string, name: string): string | undefined {
125
+ const lines = summary.split("\n");
126
+ const at = lines.findIndex((line) => line.includes(name));
127
+ if (at < 0) return undefined;
128
+ const said: string[] = [];
129
+ for (const line of lines.slice(at + 1)) {
130
+ const text = line.replace(/^[#\s*-]+/, "").trim();
131
+ if (text === "") continue;
132
+ if (/^#/.test(line) || /^\*\*/.test(line)) break;
133
+ said.push(text);
134
+ if (said.join("、").length > 120) break;
135
+ }
136
+ const all = said.join("、");
137
+ if (all === "" || all === "なし") return undefined;
138
+ return all.length > 120 ? `${all.slice(0, 120)}…` : all;
139
+ }
140
+
98
141
  const HELP = [
99
142
  "/work list Work の一覧",
100
143
  "/work show <id> Work の詳細",
@@ -102,6 +145,7 @@ const HELP = [
102
145
  "/approvals 承認待ちの一覧",
103
146
  "/approve <id> 承認して実行する。/reject <id> [理由] で拒否する",
104
147
  "/review <id> approve|reject 資格者の判断を記録する。名前と本文を順に聞く",
148
+ "/compact 会話を要約して短くする。長い会話は自動でも要約される",
105
149
  "/tools 使える Tool",
106
150
  "/quit 終わる",
107
151
  "↑ ↓ 前に送った行を入力欄に呼び戻す。いちばん下は新しい入力",
@@ -417,7 +461,10 @@ export async function createController(options: ControllerOptions): Promise<Cont
417
461
  if (name === "help") for (const h of HELP) push("line", h);
418
462
  else if (name === "quit" || name === "exit") await close();
419
463
  else if (name === "tools") await capture((write) => toolsList({ ...options, write }));
420
- else if (name === "work" && sub === "list")
464
+ else if (name === "compact") {
465
+ const outcome = await session.compact();
466
+ push(outcome.done ? "progress" : "notice", compactionLine(outcome));
467
+ } else if (name === "work" && sub === "list")
421
468
  await capture((write) => workList({ workspaceRoot: options.workspaceRoot, write }));
422
469
  else if (name === "work" && (sub === "show" || sub === "resume") && !args[1])
423
470
  push("notice", `/work ${sub} には Work の id が要ります。/work list で確かめてください。`);
@@ -559,6 +606,9 @@ export async function createController(options: ControllerOptions): Promise<Cont
559
606
  await stoppable(async (signal) => {
560
607
  try {
561
608
  const result = await session.turn(text, { signal });
609
+ if (result.compacted) {
610
+ push(result.compacted.done ? "progress" : "notice", compactionLine(result.compacted));
611
+ }
562
612
  // What the work recorded is the agent's own writing, so it stands in when the turn
563
613
  // ends with nothing said. Without this the person is left with a work that finished
564
614
  // and no answer, which is what a model that skips its summary leaves behind.