@migor/cli 0.8.3 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,36 +1,18 @@
1
- import { existsSync } from 'node:fs';
2
- import { fileURLToPath } from 'node:url';
3
- import { createApp, loadEnvFile, SystemPrompt } from '@migor/agentia';
1
+ import { createAgentApp } from './app.js';
4
2
 
5
- // 读同目录的 .env(key 写文件里即可,不必每次 export)。框架**不自动**读 .env ——
6
- // 读哪个文件、什么时候读由这里决定;已存在的真实环境变量优先,不会被文件覆盖。
7
- // 想换路径/顺序:loadEnvFile({ path: '.env.local' }) 或直接删掉这一行改用自己的加载器。
8
- loadEnvFile();
3
+ // 装配在 app.ts 里(与启动分离):`agentia dev` 的调试环要复用同一个工厂,
4
+ // 才能把「能力选择 / 工作目录」喂进 createApp。别把 createApp(...) 搬回这里。
5
+ // `.env` 也由 app.ts 读 —— 那才是 `agentia dev` 与 `npm start` **都**会经过的那条路
6
+ // (本文件在 dev 环下根本不执行),写在这里会让两个入口的行为不一致。
7
+ const app = await createAgentApp();
9
8
 
10
- // 能力目录按**本文件位置**解析,不是 cwd:开发态(src/main.ts)解析到 src/<分类>/,
11
- // 构建后(dist/main.js)解析到 dist/<分类>/ —— 从任何目录启动都成立。
12
- // ⚠️ 别改回 cwd 相对写法(形如 src/tools 的字符串):那样 `node dist/main.js` 会去加载 src 下的
13
- // .ts 源码,而装饰器不是可擦除的类型语法,Node 直接跑不了("Invalid or unexpected token")。
14
- // filter:空分类目录在构建后不存在(tsc 不为空目录产出 dist/<分类>/),而 discover 对
15
- // 显式给出的不存在路径是报错的 —— 「这类暂时没有能力」不该让启动失败。
16
- const CAPABILITY_DIRS = ['tools', 'skills', 'prompts', 'subagents'];
17
- const app = await createApp({
18
- name: '__PROJECT_NAME__',
19
- discover: CAPABILITY_DIRS.map((d) => fileURLToPath(new URL(d + '/', import.meta.url))).filter(
20
- (dir) => existsSync(dir),
21
- ),
22
- system: new SystemPrompt().add('role', '你是 __PROJECT_NAME__ 的主 agent,按任务自主调度菜单里的能力。', true),
23
- });
24
-
25
- const { result } = await app.run(
26
- [{ role: 'user', content: process.argv[2] ?? '介绍一下你自己' }],
27
- );
9
+ const { result } = await app.run([{ role: 'user', content: process.argv[2] ?? '介绍一下你自己' }]);
28
10
 
29
11
  // 注意:run 失败**不会抛**(硬失败被记进 result.error 与 trace 后正常返回)—— 不显式检查就会
30
12
  // 「打印一行空白 + 退出 0」,让首次运行(比如忘了配 ANTHROPIC_API_KEY)看起来像成功。
31
13
  if (result.error) {
32
14
  console.error(`run 失败(stopReason=${result.stopReason}):${result.error.message}`);
33
- console.error('提示:模型调用读 ANTHROPIC_API_KEY —— 填进 .env(首行 loadEnvFile() 会读)或 export 均可;');
15
+ console.error('提示:模型调用读 ANTHROPIC_API_KEY —— 填进 .env(src/app.ts 的 loadEnvFile() 会读)或 export 均可;');
34
16
  console.error(' 换端点 / 注入自定义 client 见项目内 AGENTS.md。');
35
17
  process.exitCode = 1;
36
18
  }
@@ -5,6 +5,9 @@ import type { Provider } from '@migor/agentia';
5
5
 
6
6
  /** 显式装配路线:createApp({ providers, system: ... })(与 discover 目录扫描二选一或混用) */
7
7
  export const providers: Provider[] = [
8
+ // 「工作目录」以**值**注入给需要它的能力(消费方式见 src/tools/read-file/index.ts)。
9
+ // 想改成固定路径 / 从环境变量取,改这一行即可 —— 这是显式装配路线的意义。
10
+ { provide: 'WORKDIR', useValue: process.cwd() },
8
11
  // @agentia:entries
9
12
  // @agentia:entries-end
10
13
  ];
@@ -0,0 +1,68 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import type { MessageParam, SessionStore } from '@migor/agentia';
4
+
5
+ /**
6
+ * 文件后端 `SessionStore`:整段对话历史存在一个 JSON 文件里。
7
+ *
8
+ * 为什么需要文件后端:框架自带的 `InMemorySessionStore` 是**进程内**的,重启即丢。
9
+ * 而 dev 环(以及任何重启式宿主)每次 run 都可能换进程 —— 对话历史要接得上,
10
+ * 就必须落盘。这个类只有 ~30 行,放在模板里(而不是框架里):它是**可选件**,
11
+ * 只有对话型能力用得上,而放模板里用户能直接改(换 sqlite / Redis 只改这一个文件)。
12
+ *
13
+ * ⚠️ 与 `MemoryStore` 不是一回事:那是键值**黑板**(存「状态与事实」),
14
+ * 这是**对话历史**(存 messages 序列)。两者正交,可以同时用。
15
+ */
16
+ export class FileSessionStore implements SessionStore {
17
+ constructor(private readonly file: string) {}
18
+
19
+ load(sessionId: string): MessageParam[] {
20
+ const cur = this.readAll()[sessionId];
21
+ // 返回浅拷贝:run 会把结果拼进自己的 messages 数组并继续 push,
22
+ // 直接把内部数组交出去会让那些 push 反向污染 store 里的历史。
23
+ return Array.isArray(cur) ? [...cur] : [];
24
+ }
25
+
26
+ append(sessionId: string, messages: MessageParam[]): void {
27
+ const all = this.readAll();
28
+ const cur = all[sessionId] ?? [];
29
+ for (const m of messages) cur.push(m);
30
+ all[sessionId] = cur;
31
+ mkdirSync(dirname(this.file), { recursive: true });
32
+ // 原子写:先写同目录的临时文件再 rename。直接覆写的话,进程在写一半时被杀
33
+ // 会留下半截 JSON,下一次 load 直接抛 —— 整段历史因为一次崩溃而不可读。
34
+ const tmp = `${this.file}.tmp`;
35
+ writeFileSync(tmp, JSON.stringify({ sessions: all }, null, 2), 'utf8');
36
+ renameSync(tmp, this.file);
37
+ }
38
+
39
+ /**
40
+ * 读整份文件。文件不存在 = 还没有历史(空)。
41
+ * 解析失败**响亮报错**而不是当作空历史 —— 静默当成空会把「历史读不出来」
42
+ * 伪装成「这是第一轮」,然后下一轮 append 再把它覆盖掉:整段对话无声消失。
43
+ */
44
+ private readAll(): Record<string, MessageParam[]> {
45
+ let raw: string;
46
+ try {
47
+ raw = readFileSync(this.file, 'utf8');
48
+ } catch (e) {
49
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return {};
50
+ throw new Error(`读会话文件失败(${this.file}):${(e as Error).message}`);
51
+ }
52
+ if (raw.trim().length === 0) return {};
53
+ let parsed: unknown;
54
+ try {
55
+ parsed = JSON.parse(raw);
56
+ } catch (e) {
57
+ throw new Error(
58
+ `会话文件不是合法 JSON(${this.file}):${(e as Error).message}\n` +
59
+ '它是本地调试产物(已进 .gitignore),删掉它就能重新开始一段干净的对话。',
60
+ );
61
+ }
62
+ const sessions = (parsed as { sessions?: unknown }).sessions;
63
+ if (!sessions || typeof sessions !== 'object' || Array.isArray(sessions)) {
64
+ throw new Error(`会话文件结构不对(${this.file}):应为 { "sessions": { "<id>": [...] } }`);
65
+ }
66
+ return sessions as Record<string, MessageParam[]>;
67
+ }
68
+ }
@@ -0,0 +1,70 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve, sep } from 'node:path';
3
+ import { Tool } from '@migor/agentia';
4
+
5
+ /**
6
+ * 读取**工作目录**下的一个文本文件(相对路径)。
7
+ *
8
+ * 它是脚手架自带的第二个能力,作用是把「工作目录」这条线**接通**:
9
+ * `agentia dev` 的面板上有「工作目录」控件,而只有当**某个能力真的消费它**时,
10
+ * 那个控件才有可观测的效果。做法就是这里 —— 根由 DI 注入(`WORKDIR`),
11
+ * 不在代码里写死。见 app.ts 的 providers。
12
+ *
13
+ * 换成别的形态(把 `workdir` 换成某个 API 的 baseUrl、某个数据目录)完全一样:
14
+ * 值从构造器进来,方法里用 `this.root`。
15
+ */
16
+ export default class ReadFileTool {
17
+ constructor(private readonly root: string) {
18
+ // discover 自动注册的 provider **没有 deps** —— 如果哪天 app.ts 里那行显式 provider
19
+ // 被删掉,容器会无参构造这个类,root 变成 undefined,工具就会静默地按 cwd 解析。
20
+ // 与其静默,不如在这里响亮报错。
21
+ if (typeof root !== 'string' || root.length === 0) {
22
+ throw new Error(
23
+ 'ReadFileTool 需要注入工作目录:请在 src/app.ts 的 providers 里保留 ' +
24
+ "{ provide: 'read-file', useClass: ReadFileTool, deps: ['WORKDIR'] }",
25
+ );
26
+ }
27
+ // ⚠️ **归一化**:调用方给的工作目录可能带尾斜杠(面板输入、dev.config 手写、
28
+ // `.../repo/` 粘进来都很常见),而 `safeResolve` 的越界判定是
29
+ // `abs.startsWith(root + sep)` 这类**字符串前缀**比较 —— root 不归一化时
30
+ // 拼接出来的前缀是 `/a/b//`,判定恒为假 ⇒ **每一次** read_file 都报
31
+ // 「路径越出工作目录」。那不是「误报」,是把工具整个变成坏的(且错误归因误导模型)。
32
+ this.root = resolve(root);
33
+ }
34
+
35
+ @Tool({
36
+ description: '读取工作目录下的一个文本文件(给相对路径,如 README.md)',
37
+ schema: {
38
+ type: 'object',
39
+ properties: { rel: { type: 'string', description: '相对工作目录的文件路径' } },
40
+ required: ['rel'],
41
+ additionalProperties: false,
42
+ },
43
+ strict: true,
44
+ })
45
+ // 方法名 = 模型看到的工具名 ⇒ 与其余模板一律 snake_case(doc_reviewer / note_writer …)。
46
+ // 顺带解掉一个坑:这里若叫 readFile,就与上面 import 进来的 readFile 同名 ——
47
+ // 方法体内 `this.readFile(...)` 会变成自我递归,而裸写 `readFile(...)` 读的是 import 的那个。
48
+ async read_file(input: { rel: string }): Promise<string> {
49
+ return readFile(safeResolve(this.root, input.rel), 'utf8');
50
+ }
51
+ }
52
+
53
+ /**
54
+ * 把模型给的路径约束在工作目录内(**越界响亮报错**,错误会作为 is_error 回给模型)。
55
+ *
56
+ * 这条纪律是「可指向任意目录的 agent」活得下去的前提:切目录本身不危险,
57
+ * 危险的是它悄悄跑到工作目录外面去读写。别改成静默截断 / 静默回退。
58
+ *
59
+ * ⚠️ 两端都归一化再比:`root` 由调用方给(可能带尾斜杠),而下面判越界用的是
60
+ * **字符串前缀** —— `resolve(root) + sep` 不归一化时会是 `/a/b//`,于是每一次调用
61
+ * 都误报越界。归一化放在函数内部(不只依赖构造器):本函数是导出的,调用方可能不止一个。
62
+ */
63
+ export function safeResolve(root: string, rel: string): string {
64
+ const base = resolve(root);
65
+ const abs = resolve(base, rel);
66
+ if (abs !== base && !abs.startsWith(base + sep)) {
67
+ throw new Error(`路径越出工作目录: "${rel}"(工作目录:${base})`);
68
+ }
69
+ return abs;
70
+ }
package/dist/templates.js CHANGED
@@ -90,7 +90,7 @@ function capabilityVars(name) {
90
90
  // ---------- create 命令模板 ----------
91
91
  /**
92
92
  * 脚手架 package.json(templates/package.json)。两处口径钉在这里,改动前先读:
93
- * - `scripts.dev` 走 CLI 的 dev(tsx watch + 本地 inspector 面板):与文档/其它命令同一条路,
93
+ * - `scripts.dev` 走 CLI 的 dev(本地 inspector 面板 + CLI 自己管的 runner):与文档/其它命令同一条路,
94
94
  * 而不是「npm run dev 少一个面板、CLI dev 多一个面板」两种 dev;`npm run dev -- "你的问题"`
95
95
  * 的参数由 CLI 原样透传给脚本。
96
96
  * - `@migor/cli` 装进 devDependencies(而不是让用户每次 npx 去 registry 拉):工程内
@@ -112,9 +112,56 @@ export function projectTsconfig() {
112
112
  return renderTemplate('tsconfig.json');
113
113
  }
114
114
  /**
115
- * 入口 main.ts(templates/src/main.ts)。`CAPABILITY_DIRS` 数组在模板里是字面量
116
- * `['tools', 'skills', 'prompts', 'subagents']`,必须与上面 CAPABILITY_DIR_LIST 去掉
117
- * `src/` 前缀后保持一致(顺序即装配顺序)—— templates.test.mjs 钉着这条对应关系。
115
+ * 装配入口 app.ts(templates/src/app.ts)—— **与启动分离**的那个工厂。
116
+ *
117
+ * 为什么单独一个文件(D9(d)):dev 环要驱动用户的 app,就得有个「接受选项的工厂」
118
+ * 能喂进能力选择与工作目录。`CAPABILITY_DIRS` 也在它里面,且**同时是面板能力选择器的
119
+ * 菜单来源** —— 两侧同一个常量,不会漂移。
120
+ *
121
+ * ⚠️ 它是**破坏性模板变更**的一部分:老工程只有 main.ts,`agentia dev` 会明确报错
122
+ * 并给出迁移指引(不静默降级)。迁移是机械的:把 main.ts 里 createApp(...) 那一段
123
+ * 整体搬进 app.ts 并包成工厂。
124
+ */
125
+ export function appTs(name) {
126
+ return renderTemplate('src/app.ts', { __PROJECT_NAME__: name });
127
+ }
128
+ /**
129
+ * dev 环的数据声明(templates/src/dev.config.ts)。
130
+ *
131
+ * 只放**数据**,不放逻辑 —— 逻辑副本会漂移(CLI 修了 bug,工程里那份不会变),
132
+ * 数据不会。所以 `dev.ts` 被否掉,`dev.config.ts` 留下(D8 ②)。
133
+ */
134
+ export function devConfigTs() {
135
+ return renderTemplate('src/dev.config.ts');
136
+ }
137
+ /**
138
+ * 文件后端 SessionStore(templates/src/session-store.ts)。
139
+ *
140
+ * 放在**模板**而不是框架里:它是对话型能力的**可选件**(任务型能力根本不需要对话历史,
141
+ * 对它们是负担 —— 跨仓库串味)。用户能直接改,换 sqlite / Redis 只改这一个文件。
142
+ */
143
+ export function sessionStoreTs() {
144
+ return renderTemplate('src/session-store.ts');
145
+ }
146
+ /**
147
+ * 脚手架自带的第二个能力:read-file 工具(templates/src/tools/read-file/index.ts)。
148
+ *
149
+ * 它存在的理由是**把「工作目录」这条线接通**:面板上那个文件夹控件,只有某个能力真的
150
+ * 消费了注入进来的根,才有可观测的效果。顺带它也是能力多选的第一个演示对象
151
+ * (一个工程里 ≥2 个能力,选择器才有东西可选)。
152
+ */
153
+ export function readFileToolIndexTs() {
154
+ return renderTemplate('src/tools/read-file/index.ts');
155
+ }
156
+ /**
157
+ * 入口 main.ts(templates/src/main.ts)。**薄入口**:调工厂 → 处理 result。
158
+ *
159
+ * 装配搬去了 app.ts(见上),这里只留「跑一次」。两件事的顺序与语义都不能变 ——
160
+ * 尤其 `result.error` 那段:run 失败**不抛**,不检查就是「打印空行 + 退出 0」,
161
+ * 让首次运行(比如忘了配 ANTHROPIC_API_KEY)看起来像成功。
162
+ *
163
+ * ⚠️ `loadEnvFile()` **不在**这里,在 app.ts —— dev 环只 import app.ts、从不执行本文件,
164
+ * 把读 .env 留在这边会让 `npm run dev` 与 `npm start` 对同一份 `.env` 有两个行为。
118
165
  */
119
166
  export function mainTs(name) {
120
167
  return renderTemplate('src/main.ts', { __PROJECT_NAME__: name });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@migor/cli",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
4
4
  "description": "Agentia 框架命令行工具:create 脚手架 + g 能力生成 + dev 本地可观测面板(trace inspector)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,31 +0,0 @@
1
- /**
2
- * inspector preload —— 由 dev.ts 用 `NODE_OPTIONS=--import <本文件>` 注入到【子进程】。
3
- *
4
- * 为什么需要它:`agentia dev` 用 tsx 起的是子进程,而 trace sink 必须在子进程里注册
5
- * (框架的 registerDefaultTraceSink 是进程内全局注册表)。CLI 无法在子进程外挂上,
6
- * 于是用 preload 在应用代码 import 之前抢先注册。
7
- *
8
- * 关键:必须从【用户项目】(process.cwd())解析 @migor/agentia,而不是从 CLI 的
9
- * 安装位置 —— 否则拿到的是另一个模块实例,注册表不共享,sink 静默失效。
10
- *
11
- * 失败一律降级:打印一行告警即可,绝不阻断 dev。
12
- */
13
- import { createRequire } from 'node:module';
14
- import { join } from 'node:path';
15
- import { pathToFileURL } from 'node:url';
16
- import { createInspectSink } from './inspector-sink.js';
17
- const port = Number(process.env.AGENTIA_INSPECT_PORT || 0);
18
- if (port > 0) {
19
- try {
20
- const req = createRequire(join(process.cwd(), 'package.json'));
21
- const entry = req.resolve('@migor/agentia');
22
- const mod = (await import(pathToFileURL(entry).href));
23
- if (typeof mod.registerDefaultTraceSink !== 'function') {
24
- throw new Error('@migor/agentia 未导出 registerDefaultTraceSink(版本过旧?)');
25
- }
26
- mod.registerDefaultTraceSink(createInspectSink({ port }));
27
- }
28
- catch (e) {
29
- console.warn(`[agentia] inspector 未挂载:${e.message}`);
30
- }
31
- }