ygl-trees 0.75.1 → 0.76.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.
@@ -12,6 +12,7 @@ const cordis_1 = require("@deepseek-ai/cordis");
12
12
  const db_1 = require("./db");
13
13
  class DatabaseService extends cordis_1.Service {
14
14
  db;
15
+ disposed = false;
15
16
  constructor(ctx, dbFile) {
16
17
  super(ctx, "database");
17
18
  // GPT-6.1 SOL fix: the DB lock was only applied in the profile-boot
@@ -23,8 +24,61 @@ class DatabaseService extends cordis_1.Service {
23
24
  const lock = process.env.YGL_TEST !== "1" && process.env.YGL_SERVE !== "1";
24
25
  this.db = new db_1.DB(dbFile, { lock });
25
26
  }
27
+ /** Lifecycle: Cordis calls this when the Context is disposed — the
28
+ * "Services stop with Context, resources auto-clean" promise made real.
29
+ * Closes the SQLite handle AND the cached ORM DataSource. */
30
+ stop() {
31
+ if (this.disposed)
32
+ return;
33
+ this.disposed = true;
34
+ try {
35
+ // Close the cached ORM DataSource if it was opened on this file
36
+ const { closeOrm } = require("./orm");
37
+ // closeOrm is async but stop() is sync in Cordis — fire-and-forget is
38
+ // safe here because db.close() below releases the file handle the ORM
39
+ // depends on; the ORM will see a closed connection and GC naturally.
40
+ closeOrm().catch(() => { });
41
+ }
42
+ catch { /* orm module not loaded — fine */ }
43
+ try {
44
+ this.db.close(); // releases .ygllock + closes SQLite handle
45
+ }
46
+ catch { /* already closed */ }
47
+ }
26
48
  }
27
49
  exports.DatabaseService = DatabaseService;
28
50
  function apply(ctx, config) {
29
51
  new DatabaseService(ctx, config.file);
52
+ // 0.76 bridge: eventbus (internal) → ctx.emit (Cordis public API).
53
+ // This makes "ctx.on()/emit()" work as documented — plugins can listen
54
+ // with ctx.on("ygl", handler) or ctx.on("ygl:node.unlock", handler).
55
+ // The eventbus remains the internal write-path transport.
56
+ try {
57
+ const { onEvent, onEventKind } = require("../../../core/text/src/eventbus");
58
+ const unsub = onEvent((evt) => { try {
59
+ ctx.emit("ygl", evt);
60
+ }
61
+ catch { /* */ } });
62
+ // Also bridge kind-specific channels
63
+ const kindSubs = [unsub];
64
+ const knownKinds = ["node.unlock", "node.lock", "node.add", "node.link",
65
+ "kb.add", "kb.rm", "claim.resolve", "claim.add", "task.status",
66
+ "project.new", "skill.add"];
67
+ for (const kind of knownKinds) {
68
+ kindSubs.push(onEventKind(kind, (evt) => { try {
69
+ ctx.emit(`ygl:${kind}`, evt);
70
+ }
71
+ catch { /* */ } }));
72
+ }
73
+ // Cleanup on Context dispose (best-effort — this fork has no ctx.dispose)
74
+ ctx.fiber?.collect?.(() => {
75
+ for (const u of kindSubs) {
76
+ try {
77
+ u();
78
+ }
79
+ catch { /* */ }
80
+ }
81
+ });
82
+ }
83
+ catch { /* eventbus optional */ }
30
84
  }
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.emitEvent = emitEvent;
4
+ exports.onEvent = onEvent;
5
+ exports.onEventKind = onEventKind;
6
+ /**
7
+ * Cross-domain event bus — connects logEvent writes to real-time consumers.
8
+ *
9
+ * Architecture: a process-wide EventEmitter singleton. Write services call
10
+ * `emitEvent()` after logEvent persists to the events table; consumers
11
+ * (webui SSE, user plugins, future watchers) subscribe with `onEvent()`.
12
+ *
13
+ * This makes "ctx.on reactivity" real without requiring a full Cordis
14
+ * event system — a lightweight bridge that the webui and plugins can use.
15
+ *
16
+ * @module @ygl/core-text/eventbus
17
+ */
18
+ const node_events_1 = require("node:events");
19
+ const bus = new node_events_1.EventEmitter();
20
+ bus.setMaxListeners(50); // SSE connections + plugins
21
+ /** Publish a cross-domain event (called after events table write). */
22
+ function emitEvent(evt) {
23
+ bus.emit("ygl", evt);
24
+ bus.emit(`ygl:${evt.kind}`, evt); // specific subscription channel
25
+ }
26
+ /** Subscribe to all events. Returns an unsubscribe function. */
27
+ function onEvent(handler) {
28
+ bus.on("ygl", handler);
29
+ return () => bus.off("ygl", handler);
30
+ }
31
+ /** Subscribe to a specific kind (e.g. "node.unlock"). Returns unsubscribe. */
32
+ function onEventKind(kind, handler) {
33
+ bus.on(`ygl:${kind}`, handler);
34
+ return () => bus.off(`ygl:${kind}`, handler);
35
+ }
@@ -187,13 +187,46 @@ function run(env, action, rest, args) {
187
187
  ]);
188
188
  return 0;
189
189
  case "link": {
190
- // One-command symlink: skills/ → .agents/skills/ (or ~/.agents/skills/ with --global)
191
- // so host agents (DSH, Claude, etc.) can discover them without manual junction setup.
190
+ // One-command symlink: skills/ → AI agent's skill directory.
191
+ // Supported targets:
192
+ // (default) ./.agents/skills/ (project-level, DSH/Claude)
193
+ // --global ~/.agents/skills/ (user-level, DSH/Claude)
194
+ // --ai pi ~/.pi/agent/skills/ (Pi agent)
195
+ // --ai claude ~/.claude/skills/ (Claude Code)
196
+ // --ai cursor ~/.cursor/skills/ (Cursor IDE)
197
+ // --ai custom --to <path> (any directory)
192
198
  const os = require("node:os");
193
199
  const globalFlag = args?.flags?.global === true;
194
- const targetDir = globalFlag
195
- ? path.join(os.homedir(), ".agents", "skills")
196
- : path.join(process.cwd(), ".agents", "skills");
200
+ const aiName = String(args?.flags?.ai || "").toLowerCase();
201
+ const customTo = String(args?.flags?.to || "");
202
+ // AI agent skill directory registry
203
+ const AI_SKILL_DIRS = {
204
+ pi: () => path.join(os.homedir(), ".pi", "agent", "skills"),
205
+ claude: () => path.join(os.homedir(), ".claude", "skills"),
206
+ cursor: () => path.join(os.homedir(), ".cursor", "skills"),
207
+ windsurf: () => path.join(os.homedir(), ".windsurf", "skills"),
208
+ };
209
+ let targetDir;
210
+ if (customTo) {
211
+ // --ai custom --to <path>
212
+ targetDir = path.resolve(customTo);
213
+ }
214
+ else if (aiName && AI_SKILL_DIRS[aiName]) {
215
+ // --ai <known-agent>
216
+ targetDir = AI_SKILL_DIRS[aiName]();
217
+ }
218
+ else if (aiName && aiName !== "custom") {
219
+ // --ai <unknown> → error with suggestions
220
+ (0, fmt_1.bad)(ctx, `unknown AI agent: ${aiName}`);
221
+ (0, fmt_1.out)(ctx, ` supported: ${Object.keys(AI_SKILL_DIRS).join(", ")} | custom (with --to <path>)`);
222
+ return 2;
223
+ }
224
+ else {
225
+ // default or --global
226
+ targetDir = globalFlag
227
+ ? path.join(os.homedir(), ".agents", "skills")
228
+ : path.join(process.cwd(), ".agents", "skills");
229
+ }
197
230
  // Find source skills directory (probe upward from cwd, same as repoRoot)
198
231
  let skillsDir = null;
199
232
  for (let dir = process.cwd(), i = 0; i < 6; i++, dir = path.dirname(dir)) {
@@ -852,6 +852,23 @@ function createDashboardServer(env) {
852
852
  if (typeof h.unref === "function")
853
853
  h.unref();
854
854
  }
855
+ // ── 0.76 Event-driven SSE: subscribe to the cross-domain event bus.
856
+ // Writes (node unlock, claim resolve, kb add, etc.) now emit instantly —
857
+ // SSE pushes fresh data on write instead of waiting for the 1.5s tick.
858
+ // The fingerprint tick above stays as a fallback/degradation path.
859
+ let unsubBus = null;
860
+ try {
861
+ const { onEvent } = require("../../../core/text/src/eventbus");
862
+ unsubBus = onEvent((evt) => {
863
+ if (!sseClients.size)
864
+ return;
865
+ // Event received — push immediately (no polling delay)
866
+ push("data", gatherData(env));
867
+ // Also push the event itself as a named SSE event for fine-grained consumers
868
+ push(evt.kind.replace(/\//g, "-"), evt);
869
+ });
870
+ }
871
+ catch { /* eventbus optional — fingerprint tick still covers */ }
855
872
  const server = http.createServer((req, res) => {
856
873
  const u = new URL(req.url || "/", "http://127.0.0.1");
857
874
  const url = u.pathname;
@@ -962,7 +979,16 @@ function createDashboardServer(env) {
962
979
  sseClients.clear();
963
980
  };
964
981
  server.on("close", dropLive);
965
- server.closeLive = dropLive;
982
+ server.closeLive = () => {
983
+ dropLive();
984
+ if (unsubBus) {
985
+ try {
986
+ unsubBus();
987
+ }
988
+ catch { /* */ }
989
+ unsubBus = null;
990
+ }
991
+ };
966
992
  return server;
967
993
  }
968
994
  /** Live dashboard: blocks until SIGINT/SIGTERM, then resolves so the CLI can
@@ -40,6 +40,12 @@ async function logEvent(mgr, kind, entity, entityId, payload, level = "info") {
40
40
  payload: p,
41
41
  level,
42
42
  });
43
+ // 0.76: cross-domain event bus — notify real-time consumers (SSE/plugins)
44
+ try {
45
+ const { emitEvent } = require("../../../core/text/src/eventbus");
46
+ emitEvent({ kind, entity, entityId, payload: String(payload), ts: new Date().toISOString() });
47
+ }
48
+ catch { /* eventbus optional — never block the write path */ }
43
49
  }
44
50
  // ================================================================ queries
45
51
  async function queryAdd(ds, projectId, text, engine = "", force = false) {
@@ -26,6 +26,12 @@ async function logEvent(mgr, kind, entityId, payload, level = "info") {
26
26
  const run = process.env.YGL_RUN_ID || "";
27
27
  const body = run ? JSON.stringify({ ...payload, run }) : JSON.stringify(payload ?? {});
28
28
  await mgr.query(`INSERT INTO events(id,ts,actor,kind,entity,entity_id,payload,level) VALUES(?,?,?,?,?,?,?,?)`, [(0, text_1.shortId)([kind, entityId], 12), (0, text_1.nowIso)(), "cli", kind, "node", entityId, body, level]);
29
+ // 0.76: cross-domain event bus — notify real-time consumers (SSE/plugins)
30
+ try {
31
+ const { emitEvent } = require("../../../core/text/src/eventbus");
32
+ emitEvent({ kind, entity: "node", entityId, payload: String(body), ts: new Date().toISOString() });
33
+ }
34
+ catch { /* eventbus optional — never block the write path */ }
29
35
  }
30
36
  async function reaches(mgr, treeId, fromId, toId) {
31
37
  const rows = (await mgr.query(`SELECT 1 AS hit FROM node_closure WHERE tree_id=? AND ancestor=? AND descendant=? AND depth>0`, [treeId, fromId, toId]));
@@ -134,8 +140,8 @@ async function buildIndex(mgr, treeId) {
134
140
  inDegree[n.key] = (requiredParents.get(n.id) || []).length;
135
141
  for (const n of nodes)
136
142
  await mgr.query(`UPDATE nodes SET tier=? WHERE id=? AND tier<>?`, [tierOf.get(n.id) ?? 0, n.id, tierOf.get(n.id) ?? 0]);
137
- await mgr.query(`INSERT INTO node_index(tree_id,topo_order,tiers,in_degree,built_at,dirty) VALUES(?,?,?,?,?,0)
138
- ON CONFLICT(tree_id) DO UPDATE SET topo_order=excluded.topo_order, tiers=excluded.tiers,
143
+ await mgr.query(`INSERT INTO node_index(tree_id,topo_order,tiers,in_degree,built_at,dirty) VALUES(?,?,?,?,?,0)
144
+ ON CONFLICT(tree_id) DO UPDATE SET topo_order=excluded.topo_order, tiers=excluded.tiers,
139
145
  in_degree=excluded.in_degree, built_at=excluded.built_at, dirty=0`, [treeId, JSON.stringify(topo), JSON.stringify(tiers), JSON.stringify(inDegree), (0, text_1.nowIso)()]);
140
146
  return { topo, tiers, inDegree };
141
147
  }
@@ -222,10 +228,10 @@ async function link(ds, treeId, parentKey, childKey, o = {}) {
222
228
  const before = (await mgr.query(`SELECT id FROM node_edges WHERE parent_id=? AND child_id=? AND rel=?`, [p[0].id, c[0].id, rel]));
223
229
  const duplicate = before.length > 0;
224
230
  const id = duplicate ? before[0].id : (0, text_1.shortId)(["edge", p[0].id, c[0].id, rel], 12);
225
- await mgr.query(`INSERT INTO node_edges(id,tree_id,parent_id,child_id,rel,mode,required,min_level,weight,note,created)
226
- VALUES(?,?,?,?,?,?,?,?,?,?,?)
227
- ON CONFLICT(parent_id,child_id,rel) DO UPDATE SET
228
- mode=excluded.mode, required=excluded.required, min_level=excluded.min_level,
231
+ await mgr.query(`INSERT INTO node_edges(id,tree_id,parent_id,child_id,rel,mode,required,min_level,weight,note,created)
232
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)
233
+ ON CONFLICT(parent_id,child_id,rel) DO UPDATE SET
234
+ mode=excluded.mode, required=excluded.required, min_level=excluded.min_level,
229
235
  weight=excluded.weight, note=excluded.note`, [id, treeId, p[0].id, c[0].id, rel, mode, o.required === false ? 0 : 1, o.min_level ?? 1, o.weight ?? 1, o.note || "", (0, text_1.nowIso)()]);
230
236
  await rebuildClosure(mgr, treeId);
231
237
  await logEvent(mgr, "node.link", id, { tree: treeId, parent: parentKey, child: childKey, rel, mode, required: o.required !== false, duplicate });
@@ -256,7 +262,7 @@ async function condAdd(ds, treeId, keyOrId, o) {
256
262
  if (!db_1.COND_KINDS.includes(kind))
257
263
  throw new Error(`kind must be one of ${db_1.COND_KINDS.join("|")}`);
258
264
  const id = (0, text_1.shortId)(["cond", n[0].id, o.name], 12);
259
- await mgr.query(`INSERT INTO node_conditions(id,node_id,name,kind,expr,op,value,required,effect,weight,note)
265
+ await mgr.query(`INSERT INTO node_conditions(id,node_id,name,kind,expr,op,value,required,effect,weight,note)
260
266
  VALUES(?,?,?,?,?,?,?,?,?,?,?)`, [id, n[0].id, o.name, kind, o.expr || o.name, o.op || ">=", o.value || "", o.required === false ? 0 : 1, o.effect || "gate", o.weight ?? 1, o.note || ""]);
261
267
  await markDirty(mgr, treeId);
262
268
  await logEvent(mgr, "node.cond", id, { tree: treeId, node: keyOrId, name: o.name, kind, op: o.op || ">=", required: o.required !== false });
@@ -269,7 +275,7 @@ async function stateSet(ds, treeId, keyOrId, status, context = "default", level
269
275
  const n = (await mgr.query(`SELECT id FROM nodes WHERE tree_id=? AND (key=? OR id=?)`, [treeId, keyOrId, keyOrId]));
270
276
  if (!n.length)
271
277
  throw new Error(`node not found: ${keyOrId}`);
272
- await mgr.query(`INSERT INTO node_state(tree_id,context,node_id,status,level,progress,updated) VALUES(?,?,?,?,?,?,?)
278
+ await mgr.query(`INSERT INTO node_state(tree_id,context,node_id,status,level,progress,updated) VALUES(?,?,?,?,?,?,?)
273
279
  ON CONFLICT(tree_id,context,node_id) DO UPDATE SET status=excluded.status, level=excluded.level, updated=excluded.updated`, [treeId, context, n[0].id, status, level, status === "unlocked" ? 1 : 0, (0, text_1.nowIso)()]);
274
280
  // R10.1: unlock/lock are the timeline anchors for R11's time axis
275
281
  await logEvent(mgr, status === "unlocked" ? "node.unlock" : "node.lock", n[0].id, {
@@ -414,10 +420,10 @@ async function importTree(ds, spec, opts = {}) {
414
420
  for (const e of edges) {
415
421
  const pid = nodeId.get(String(e.parent));
416
422
  const cid = nodeId.get(String(e.child));
417
- await mgr.query(`INSERT INTO node_edges(id,tree_id,parent_id,child_id,rel,mode,required,min_level,weight,note,created)
418
- VALUES(?,?,?,?,?,?,?,?,?,?,?)
419
- ON CONFLICT(parent_id,child_id,rel) DO UPDATE SET
420
- mode=excluded.mode, required=excluded.required, min_level=excluded.min_level,
423
+ await mgr.query(`INSERT INTO node_edges(id,tree_id,parent_id,child_id,rel,mode,required,min_level,weight,note,created)
424
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)
425
+ ON CONFLICT(parent_id,child_id,rel) DO UPDATE SET
426
+ mode=excluded.mode, required=excluded.required, min_level=excluded.min_level,
421
427
  weight=excluded.weight, note=excluded.note`, [
422
428
  (0, text_1.shortId)(["edge", tid, pid, cid, String(e.rel)], 12), tid, pid, cid,
423
429
  String(e.rel), String(e.mode || "AND"), e.required === false ? 0 : 1,
@@ -427,7 +433,7 @@ async function importTree(ds, spec, opts = {}) {
427
433
  for (const c of conds) {
428
434
  const nid = nodeId.get(String(c.node));
429
435
  await mgr.query(`DELETE FROM node_conditions WHERE node_id=? AND name=?`, [nid, String(c.name)]);
430
- await mgr.query(`INSERT INTO node_conditions(id,node_id,name,kind,expr,op,value,required,effect,weight,note)
436
+ await mgr.query(`INSERT INTO node_conditions(id,node_id,name,kind,expr,op,value,required,effect,weight,note)
431
437
  VALUES(?,?,?,?,?,?,?,?,?,?,?)`, [
432
438
  (0, text_1.shortId)(["cond", tid, nid, String(c.name)], 10), nid, String(c.name),
433
439
  String(c.kind || "flag"), String(c.expr || ""), String(c.op || ">="), String(c.value ?? ""),
@@ -93,3 +93,22 @@ repo-root/
93
93
  | `plugin list` 显示 ✗ 不可解析 | `plugin remove <name>` 后重装;或 `doctor` 看 plugin_profile 项 |
94
94
  | 启动报 "N plugin issue(s) during startup" | 读终端给出的 report 路径(`$YGL_HOME/logs/startup-*.log`) |
95
95
  | 装了命令组不生效 | 命令组在**下次进程启动**时注册(CLI 每命令独立进程,自然生效) |
96
+
97
+
98
+ ## 事件监听(0.76+)
99
+
100
+ 插件可以通过 Cordis 标准 `ctx.on` 监听跨域事件:
101
+
102
+ ```typescript
103
+ // 监听全部事件
104
+ ctx.on("ygl", (evt) => {
105
+ console.log(evt.kind, evt.entityId); // e.g. "node.unlock" "sn123"
106
+ });
107
+
108
+ // 监听特定类型
109
+ ctx.on("ygl:node.unlock", (evt) => { /* ... */ });
110
+ ctx.on("ygl:kb.add", (evt) => { /* ... */ });
111
+ ctx.on("ygl:claim.resolve", (evt) => { /* ... */ });
112
+ ```
113
+
114
+ 事件在 logEvent 落表后即时发出(事件驱动 SSE 同通道)。支持的事件类型见 `packages/core/text/src/eventbus.ts`。
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # ygl-trees v0.74.0 — AI Agent Research Workbench
1
+ # ygl-trees v0.76.1 — AI Agent Research Workbench
2
2
 
3
3
  > Single SQLite source of truth. Research → Production → Knowledge Reuse + Tech Tree.
4
4
  > CLI for AI agents: every command supports --json (machine-readable, including errors).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ygl-trees",
3
- "version": "0.75.1",
3
+ "version": "0.76.1",
4
4
  "description": "猿光 · 科技树 — database-centric CLI for research → production → knowledge reuse, built for AI agents.",
5
5
  "type": "commonjs",
6
6
  "main": "dist/apps/cli/index.js",
@@ -25,10 +25,10 @@
25
25
  "node-sqlite3-wasm": "^0.8.61",
26
26
  "reflect-metadata": "^0.2.2",
27
27
  "typeorm": "^1.1.2",
28
- "@deepseek-ai/cordis": "~4.0.5-alpha.2",
29
- "@deepseek-ai/schemastery": "~3.18.5-alpha.2",
30
28
  "@deepseek-ai/cordis-plugin-timer": "~1.1.7-alpha.2",
31
- "@deepseek-ai/cosmokit": "~1.8.6-alpha.2"
29
+ "@deepseek-ai/cosmokit": "~1.8.6-alpha.2",
30
+ "@deepseek-ai/cordis": "~4.0.5-alpha.2",
31
+ "@deepseek-ai/schemastery": "~3.18.5-alpha.2"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "^20.0.0",