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 +23 -2
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.js +9 -4
- package/dist/commands/knowledge-add.d.ts +16 -0
- package/dist/commands/knowledge-add.js +91 -0
- package/dist/commands/knowledge.d.ts +15 -0
- package/dist/commands/knowledge.js +62 -0
- package/dist/labels.js +1 -0
- package/dist/preview.js +3 -5
- package/dist/tui/controller.d.ts +6 -1
- package/dist/tui/controller.js +53 -0
- package/package.json +5 -5
- package/src/bin.ts +23 -3
- package/src/commands/init.ts +9 -4
- package/src/commands/knowledge-add.ts +128 -0
- package/src/commands/knowledge.ts +84 -0
- package/src/labels.ts +1 -0
- package/src/preview.ts +3 -5
- package/src/tui/controller.ts +51 -1
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: {
|
|
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] ?? "";
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/init.js
CHANGED
|
@@ -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)), {
|
|
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
package/dist/preview.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
|
43
|
+
const before = await readWorkspaceTextIfAny(workspaceRoot, path);
|
|
46
44
|
if (before === undefined) {
|
|
47
45
|
const lines = content.split("\n");
|
|
48
46
|
return cap([
|
package/dist/tui/controller.d.ts
CHANGED
|
@@ -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
|
package/dist/tui/controller.js
CHANGED
|
@@ -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.
|
|
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.
|
|
53
|
-
"@openshain/core": "0.
|
|
54
|
-
"@openshain/mcp": "0.
|
|
55
|
-
"@openshain/tools": "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: {
|
|
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] ?? "";
|
package/src/commands/init.ts
CHANGED
|
@@ -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)), {
|
|
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
package/src/preview.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
|
61
|
+
const before = await readWorkspaceTextIfAny(workspaceRoot, path);
|
|
64
62
|
if (before === undefined) {
|
|
65
63
|
const lines = content.split("\n");
|
|
66
64
|
return cap([
|
package/src/tui/controller.ts
CHANGED
|
@@ -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 === "
|
|
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.
|