gisool 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +136 -2
  3. package/dist/api/routes.js +166 -0
  4. package/dist/api/security.js +86 -0
  5. package/dist/api/sse.js +55 -0
  6. package/dist/api/static.js +32 -0
  7. package/dist/catalog/fields.js +146 -0
  8. package/dist/catalog/index.js +133 -0
  9. package/dist/catalog/scanner.js +185 -0
  10. package/dist/catalog/uri.js +41 -0
  11. package/dist/catalog/watcher.js +59 -0
  12. package/dist/cli/config-writer.js +255 -0
  13. package/dist/cli/doctor.js +197 -0
  14. package/dist/cli/eval.js +64 -0
  15. package/dist/cli/export-import.js +66 -0
  16. package/dist/cli/init.js +271 -0
  17. package/dist/cli/main.js +165 -0
  18. package/dist/cli/open.js +24 -0
  19. package/dist/cli/serve.js +144 -0
  20. package/dist/config.js +78 -0
  21. package/dist/dashboard/assets/app.js +256 -0
  22. package/dist/dashboard/assets/chunk-4U4GNPH4.js +17 -0
  23. package/dist/dashboard/assets/chunk-5DFWNJC2.js +1 -0
  24. package/dist/dashboard/assets/chunk-JPIEF5XO.js +17 -0
  25. package/dist/dashboard/assets/chunk-ZBUKVJOI.js +1 -0
  26. package/dist/dashboard/assets/styles.css +487 -0
  27. package/dist/dashboard/index.html +15 -0
  28. package/dist/ingest/attribution.js +55 -0
  29. package/dist/ingest/hooks.js +168 -0
  30. package/dist/ingest/mcp-recorder.js +41 -0
  31. package/dist/ingest/otlp.js +160 -0
  32. package/dist/ingest/pipeline.js +100 -0
  33. package/dist/ingest/redact.js +36 -0
  34. package/dist/ingest/runtimes.js +25 -0
  35. package/dist/mcp/http.js +84 -0
  36. package/dist/mcp/skills-extension.js +35 -0
  37. package/dist/mcp/stdio-proxy.js +53 -0
  38. package/dist/mcp/tools.js +164 -0
  39. package/dist/packs/claude-code.js +157 -0
  40. package/dist/packs/codex.js +148 -0
  41. package/dist/packs/cursor.js +95 -0
  42. package/dist/packs/gajae.js +7 -0
  43. package/dist/packs/gemini.js +88 -0
  44. package/dist/packs/json-config.js +51 -0
  45. package/dist/packs/omo.js +149 -0
  46. package/dist/packs/openclaw.js +119 -0
  47. package/dist/packs/registry.js +13 -0
  48. package/dist/search/fts.js +61 -0
  49. package/dist/search/fusion.js +50 -0
  50. package/dist/search/guidance.js +7 -0
  51. package/dist/search/index.js +74 -0
  52. package/dist/search/lexical.js +69 -0
  53. package/dist/server.js +216 -0
  54. package/dist/store/db.js +74 -0
  55. package/dist/store/export.js +199 -0
  56. package/dist/store/index.js +13 -0
  57. package/dist/store/queries.js +458 -0
  58. package/dist/store/schema.sql +96 -0
  59. package/dist/store/writes.js +172 -0
  60. package/dist/types.js +1 -0
  61. package/dist/version.js +19 -0
  62. package/package.json +46 -4
  63. package/packs/claude-code/README.md +5 -0
  64. package/packs/claude-code/settings.template.json +24 -0
  65. package/packs/codex/README.md +5 -0
  66. package/packs/codex/config.template.toml +11 -0
  67. package/packs/cursor/hooks.example.json +8 -0
  68. package/packs/cursor/mcp.example.json +8 -0
  69. package/packs/gajae/extension/discovery.md +5 -0
  70. package/packs/gajae/extension/index.js +44 -0
  71. package/packs/gemini/settings.example.json +16 -0
  72. package/packs/hook-post.mjs +29 -0
  73. package/packs/omo/extension/discovery.md +5 -0
  74. package/packs/omo/extension/index.js +44 -0
  75. package/packs/openclaw/.codex-plugin/plugin.json +11 -0
  76. package/packs/openclaw/.mcp.json +12 -0
  77. package/packs/openclaw/gisool.json +1 -0
  78. package/packs/openclaw/hooks/gisool-discovery/HOOK.md +8 -0
  79. package/packs/openclaw/hooks/gisool-discovery/handler.js +27 -0
  80. package/packs/openclaw/index.mjs +47 -0
  81. package/packs/openclaw/openclaw.plugin.json +22 -0
  82. package/packs/openclaw/package.json +11 -0
  83. package/src/store/schema.sql +96 -0
  84. package/src/store/task-types.json +15 -0
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 changeroa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md CHANGED
@@ -1,3 +1,137 @@
1
- # Temporary Holding Version
1
+ # gisool
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ gisool is a local skill router. One daemon on `127.0.0.1` indexes the `SKILL.md` folders already on your machine,
4
+ serves them to your coding agents over MCP (`search_skills`, `load_skill`, `read_skill_file`), records which skills
5
+ each agent session searched, loaded, and read, and shows that history in a local dashboard.
6
+
7
+ Supported runtimes: Claude Code, Codex, Gemini CLI, Cursor, OmO, Gajae Code, and OpenClaw.
8
+
9
+ **Privacy: everything stays on this machine.** The daemon listens on `127.0.0.1` only, every MCP, ingest, and API
10
+ request needs the local bearer token, and nothing is sent to any remote service. Prompts, search queries, and skill
11
+ usage are stored in a SQLite database under `~/.gisool/`.
12
+
13
+ ## Install
14
+
15
+ Requires Node.js 24.15 or newer on macOS or Linux.
16
+
17
+ ```sh
18
+ npm --prefix local ci && npm --prefix local run build && npm link ./local
19
+ ```
20
+
21
+ This puts the `gisool` command on your `PATH`.
22
+
23
+ ## Connect your agents
24
+
25
+ Preview what would change, without writing anything:
26
+
27
+ ```sh
28
+ gisool init --dry-run
29
+ ```
30
+
31
+ The dry run prints a diff for every runtime config gisool would touch (MCP registration, OpenTelemetry export to the
32
+ daemon, capture hooks) and the service it would install. Then apply it:
33
+
34
+ ```sh
35
+ gisool init # every detected runtime
36
+ gisool init --runtime codex,claude-code
37
+ gisool init --disable-remote # also turn off a remote gisul registration
38
+ gisool init --no-service # do not install the LaunchAgent / systemd user unit
39
+ ```
40
+
41
+ Every run backs up the files it changes under `~/.gisool/backups/`. Undo with:
42
+
43
+ ```sh
44
+ gisool init --rollback # newest run
45
+ gisool init --rollback --all # every run
46
+ ```
47
+
48
+ ## Run
49
+
50
+ `gisool init` installs a LaunchAgent (macOS) or systemd user unit (Linux) that keeps the daemon running. To run it in
51
+ the foreground instead:
52
+
53
+ ```sh
54
+ gisool serve # default port 8799
55
+ gisool serve --port 8800
56
+ ```
57
+
58
+ Open the dashboard (sessions, per-session trace, flow, and weekday-by-hour heatmap):
59
+
60
+ ```sh
61
+ gisool open # opens the browser
62
+ gisool open --print # prints the URL only
63
+ ```
64
+
65
+ Check the installation:
66
+
67
+ ```sh
68
+ gisool doctor # service, daemon, token mode, FTS5, roots, index freshness, hooks, backups
69
+ gisool doctor --json
70
+ ```
71
+
72
+ Measure search quality against recorded or hand-written cases:
73
+
74
+ ```sh
75
+ gisool eval
76
+ gisool eval --cases cases.json --json
77
+ ```
78
+
79
+ ## Export and import
80
+
81
+ Move your gisool setup (skill roots, config, eval cases, aliases) to another machine:
82
+
83
+ ```sh
84
+ gisool export ~/gisool-backup # settings only
85
+ gisool export ~/gisool-backup --with-history # also sessions, events, and loads
86
+ gisool import ~/gisool-backup --dry-run # preview what would be added
87
+ gisool import ~/gisool-backup # merge; existing keys are kept
88
+ ```
89
+
90
+ Import never overwrites existing settings. Run `gisool init` afterwards to connect the runtimes on the new machine.
91
+
92
+ ## Settings
93
+
94
+ `~/.gisool/config.json` (created on first run, mode 0600; `GISOOL_HOME` moves the whole directory):
95
+
96
+ ```json
97
+ {
98
+ "port": 8799,
99
+ "captureLevel": "full",
100
+ "retentionDays": null,
101
+ "roots": [{ "id": "agents", "path": "~/.agents/skills" }],
102
+ "runtimes": {}
103
+ }
104
+ ```
105
+
106
+ - `captureLevel` controls how prompt and search text is stored:
107
+ - `full` (default): the text as sent.
108
+ - `redacted`: secrets (tokens, keys, passwords) are masked and the text is cut to 2048 characters.
109
+ - `metadata`: no text at all, only its SHA-256 digest and length. The dashboard shows "prompt not captured".
110
+ - `retentionDays`: `null` keeps history forever; a number deletes sessions older than that many days at startup and
111
+ once a day.
112
+ - `roots`: skill folders to index in place. Defaults cover `~/.agents/skills`, `~/.claude/skills`, `~/.codex/skills`,
113
+ `~/.gemini/skills`, `~/.omo/agent/skills`, `~/.gjc/agent/skills`, `~/.openclaw/skills`, and `~/gisul/skills`.
114
+ gisool never copies or edits skill files.
115
+
116
+ Restart the daemon after editing the config.
117
+
118
+ ## Files
119
+
120
+ | Path | Purpose |
121
+ | --- | --- |
122
+ | `~/.gisool/config.json` | settings above |
123
+ | `~/.gisool/token` | bearer token (0600) shared with the runtime configs |
124
+ | `~/.gisool/gisool.db` | SQLite store: skills index, sessions, events, loads |
125
+ | `~/.gisool/logs/daemon.log` | daemon log, rotated at 5 MB |
126
+ | `~/.gisool/backups/` | per-run backups used by `gisool init --rollback` |
127
+
128
+ ## Develop
129
+
130
+ ```sh
131
+ npm --prefix local run build
132
+ npm --prefix local run typecheck
133
+ npm --prefix local test # unit tests plus test/e2e, which spawns the real daemon in a sandbox HOME
134
+ node local/bench/search.mjs --check
135
+ ```
136
+
137
+ Architecture: [docs/gisool.md](../docs/gisool.md).
@@ -0,0 +1,166 @@
1
+ import { ingestHook, ingestOtlp } from "../ingest/pipeline.js";
2
+ import { isRuntimeId, RUNTIME_IDS } from "../ingest/runtimes.js";
3
+ import { deny } from "./security.js";
4
+ const STATES = ["loaded", "exposed_only", "no_match", "aborted", "not_used"];
5
+ const CLIENT_ERROR_CODES = new Set(["invalid_tz", "bad_filter", "bad_request"]);
6
+ class BadRequest extends Error {
7
+ code;
8
+ constructor(code, message) {
9
+ super(message);
10
+ this.code = code;
11
+ }
12
+ }
13
+ function one(value) {
14
+ if (value === undefined)
15
+ return undefined;
16
+ if (Array.isArray(value))
17
+ return value.map(String).join(",");
18
+ return String(value);
19
+ }
20
+ function intParam(q, key, range) {
21
+ const raw = one(q[key]);
22
+ if (raw === undefined || raw === "")
23
+ return undefined;
24
+ const n = Number(raw);
25
+ if (!Number.isInteger(n) || (range && (n < range[0] || n > range[1])))
26
+ throw new BadRequest("bad_filter", `${key} must be an integer${range ? ` in ${range[0]}..${range[1]}` : ""}`);
27
+ return n;
28
+ }
29
+ function listParam(q, key, allowed) {
30
+ const raw = one(q[key]);
31
+ if (raw === undefined || raw === "")
32
+ return undefined;
33
+ const items = raw.split(",").map((s) => s.trim()).filter(Boolean);
34
+ if (allowed)
35
+ for (const item of items)
36
+ if (!allowed.includes(item))
37
+ throw new BadRequest("bad_filter", `unknown ${key} value: ${item}`);
38
+ return items;
39
+ }
40
+ export function parseFilter(q) {
41
+ const f = {};
42
+ const from = intParam(q, "from");
43
+ const to = intParam(q, "to");
44
+ const runtime = listParam(q, "runtime", RUNTIME_IDS);
45
+ const skill = listParam(q, "skill");
46
+ const state = listParam(q, "state", STATES);
47
+ const dow = intParam(q, "dow", [0, 6]);
48
+ const hour = intParam(q, "hour", [0, 23]);
49
+ const hourSpan = intParam(q, "hourSpan", [1, 24]);
50
+ const text = one(q.q)?.trim();
51
+ const tz = one(q.tz);
52
+ if (from !== undefined)
53
+ f.from = from;
54
+ if (to !== undefined)
55
+ f.to = to;
56
+ if (runtime)
57
+ f.runtime = runtime;
58
+ if (skill)
59
+ f.skill = skill;
60
+ if (state)
61
+ f.state = state;
62
+ if (dow !== undefined)
63
+ f.dow = dow;
64
+ if (hour !== undefined)
65
+ f.hour = hour;
66
+ if (hourSpan !== undefined)
67
+ f.hourSpan = hourSpan;
68
+ if (text)
69
+ f.q = text;
70
+ if (tz)
71
+ f.tz = tz;
72
+ return f;
73
+ }
74
+ function defaultTz() {
75
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
76
+ }
77
+ function handle(log, fn) {
78
+ return (req, res) => {
79
+ try {
80
+ fn(req, res);
81
+ }
82
+ catch (error) {
83
+ const code = error.code;
84
+ if (error instanceof BadRequest || (typeof code === "string" && CLIENT_ERROR_CODES.has(code))) {
85
+ return deny(res, 400, String(code), { message: error.message });
86
+ }
87
+ log(`${req.method} ${req.path} failed: ${error.stack ?? String(error)}`);
88
+ deny(res, 500, "internal_error");
89
+ }
90
+ };
91
+ }
92
+ export function registerRoutes(app, deps) {
93
+ const { store, catalog, log } = deps;
94
+ app.get("/healthz", (_req, res) => {
95
+ res.json({ ok: true, version: deps.version, name: "gisool" });
96
+ });
97
+ app.post("/ingest/otlp/v1/logs", (req, res) => {
98
+ const body = req.body;
99
+ res.status(202).json({ partialSuccess: {} });
100
+ queueMicrotask(() => {
101
+ const result = ingestOtlp(body, deps.ingest);
102
+ if (result.status !== 200)
103
+ log(`otlp batch rejected: ${result.body.code}: ${result.body.message}`);
104
+ });
105
+ });
106
+ app.post("/ingest/hook/:runtime", (req, res) => {
107
+ const runtime = String(req.params.runtime);
108
+ if (!isRuntimeId(runtime)) {
109
+ store.countIngest("rejected", "unknown_runtime");
110
+ return deny(res, 400, "unknown_runtime");
111
+ }
112
+ const body = req.body;
113
+ res.status(202).json({ accepted: true });
114
+ queueMicrotask(() => {
115
+ ingestHook(runtime, body, deps.ingest);
116
+ });
117
+ });
118
+ app.get("/api/sessions", handle(log, (req, res) => {
119
+ res.json(store.sessions(parseFilter(req.query)));
120
+ }));
121
+ app.get("/api/sessions/:id/trace", handle(log, (req, res) => {
122
+ res.json(store.trace(String(req.params.id)));
123
+ }));
124
+ app.get("/api/flow", handle(log, (req, res) => {
125
+ const unit = one(req.query.unit) ?? "first_load";
126
+ if (unit !== "first_load" && unit !== "all_loads")
127
+ throw new BadRequest("bad_request", "unit must be first_load or all_loads");
128
+ const top = Math.min(15, intParam(req.query, "top", [1, 1000]) ?? 7);
129
+ res.json(store.flow(parseFilter(req.query), unit, top));
130
+ }));
131
+ app.get("/api/heatmap", handle(log, (req, res) => {
132
+ const filter = parseFilter(req.query);
133
+ const tz = filter.tz ?? defaultTz();
134
+ const weekStart = intParam(req.query, "weekStart", [0, 1]) ?? 1;
135
+ const bucket = intParam(req.query, "bucket") ?? 1;
136
+ if (bucket !== 1 && bucket !== 2 && bucket !== 4)
137
+ throw new BadRequest("bad_request", "bucket must be 1, 2, or 4");
138
+ res.json(store.heatmap(filter, tz, weekStart, bucket));
139
+ }));
140
+ app.get("/api/skill-week", handle(log, (req, res) => {
141
+ res.json(store.skillWeek(parseFilter(req.query)));
142
+ }));
143
+ app.get("/api/stats", handle(log, (req, res) => {
144
+ res.json(store.stats(parseFilter(req.query)));
145
+ }));
146
+ app.get("/api/skills", handle(log, (_req, res) => {
147
+ res.json(catalog.list());
148
+ }));
149
+ app.post("/api/eval-cases", handle(log, (req, res) => {
150
+ const body = (req.body ?? {});
151
+ const query = typeof body.query === "string" ? body.query.trim() : "";
152
+ if (!query)
153
+ throw new BadRequest("bad_request", "query is required");
154
+ for (const key of ["expectedUri", "fromTraceId"]) {
155
+ if (body[key] !== undefined && typeof body[key] !== "string")
156
+ throw new BadRequest("bad_request", `${key} must be a string`);
157
+ }
158
+ store.addEvalCase({
159
+ query,
160
+ ...(typeof body.expectedUri === "string" ? { expectedUri: body.expectedUri } : {}),
161
+ ...(typeof body.fromTraceId === "string" ? { fromTraceId: body.fromTraceId } : {}),
162
+ });
163
+ res.status(201).json({ ok: true });
164
+ }));
165
+ app.get("/api/events/stream", (req, res) => deps.sse.handler(req, res));
166
+ }
@@ -0,0 +1,86 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ export const DASH_COOKIE = "gisool_dash";
3
+ export function deny(res, status, error, extra = {}) {
4
+ res.status(status).json({ error, ...extra });
5
+ }
6
+ export function safeEqual(a, b) {
7
+ const x = Buffer.from(a);
8
+ const y = Buffer.from(b);
9
+ return x.length === y.length && timingSafeEqual(x, y);
10
+ }
11
+ export function dashCookieValue(token) {
12
+ return createHmac("sha256", token).update("dash").digest("hex");
13
+ }
14
+ export function bearerOf(req) {
15
+ const header = req.header("authorization") ?? "";
16
+ return header.startsWith("Bearer ") ? header.slice(7).trim() : "";
17
+ }
18
+ export function cookieOf(req, name) {
19
+ for (const part of (req.header("cookie") ?? "").split(";")) {
20
+ const eq = part.indexOf("=");
21
+ if (eq > 0 && part.slice(0, eq).trim() === name)
22
+ return part.slice(eq + 1).trim();
23
+ }
24
+ return undefined;
25
+ }
26
+ /**
27
+ * DNS-rebinding and cross-site guard. `port()` is read per request so a server bound to port 0 checks its real port.
28
+ * Host must be 127.0.0.1:<port> or localhost:<port>; a present Origin must be the matching http origin;
29
+ * `Sec-Fetch-Site: cross-site` is always refused.
30
+ */
31
+ export function localOnly(port) {
32
+ return (req, res, next) => {
33
+ const p = port();
34
+ const hosts = [`127.0.0.1:${p}`, `localhost:${p}`];
35
+ if (!hosts.includes(req.header("host") ?? ""))
36
+ return deny(res, 403, "host_not_allowed");
37
+ const origin = req.header("origin");
38
+ if (origin !== undefined && !hosts.some((h) => origin === `http://${h}`))
39
+ return deny(res, 403, "origin_not_allowed");
40
+ if ((req.header("sec-fetch-site") ?? "").toLowerCase() === "cross-site")
41
+ return deny(res, 403, "cross_site_not_allowed");
42
+ next();
43
+ };
44
+ }
45
+ /**
46
+ * `/mcp` and `/ingest/*` take only the bearer token; `/api/*` also accepts the dashboard cookie; everything else is public.
47
+ * The path is classified lowercased and percent-decoded so case or encoding variants (`/API/`, `/%61pi/`) cannot
48
+ * skip the gate even if a router ever matches them; a path that does not decode is refused.
49
+ */
50
+ export function authGate(token) {
51
+ const cookie = dashCookieValue(token);
52
+ return (req, res, next) => {
53
+ let path;
54
+ try {
55
+ path = decodeURIComponent(req.path).toLowerCase();
56
+ }
57
+ catch {
58
+ return deny(res, 400, "bad_path");
59
+ }
60
+ const bearerOnly = path === "/mcp" || path.startsWith("/mcp/") || path.startsWith("/ingest/");
61
+ const api = path === "/api" || path.startsWith("/api/");
62
+ if (!bearerOnly && !api)
63
+ return next();
64
+ if (safeEqual(bearerOf(req), token))
65
+ return next();
66
+ if (api) {
67
+ const presented = cookieOf(req, DASH_COOKIE);
68
+ if (presented !== undefined && safeEqual(presented, cookie))
69
+ return next();
70
+ }
71
+ deny(res, 401, "unauthorized");
72
+ };
73
+ }
74
+ const MUTATIONS = new Set(["POST", "PUT", "PATCH"]);
75
+ /** Mutations must declare a JSON body; OTLP protobuf gets a hint to switch the exporter to http/json. */
76
+ export function requireJson(onReject) {
77
+ return (req, res, next) => {
78
+ if (!MUTATIONS.has(req.method) || req.is("application/json"))
79
+ return next();
80
+ onReject?.(req);
81
+ const protobuf = req.path.startsWith("/ingest/otlp/") && /protobuf/i.test(req.header("content-type") ?? "");
82
+ deny(res, 415, "unsupported_media_type", protobuf
83
+ ? { hint: "gisool accepts OTLP/HTTP JSON only; set OTEL_EXPORTER_OTLP_PROTOCOL=http/json (or OTEL_EXPORTER_OTLP_LOGS_PROTOCOL=http/json)" }
84
+ : { hint: "send Content-Type: application/json" });
85
+ };
86
+ }
@@ -0,0 +1,55 @@
1
+ export function createSseHub(lookup, log, heartbeatMs = 25_000) {
2
+ const clients = new Set();
3
+ const pending = new Set();
4
+ let scheduled = false;
5
+ const flush = () => {
6
+ scheduled = false;
7
+ const ids = [...pending];
8
+ pending.clear();
9
+ if (clients.size === 0)
10
+ return;
11
+ for (const id of ids) {
12
+ let row;
13
+ try {
14
+ row = lookup(id);
15
+ }
16
+ catch (error) {
17
+ log(`sse lookup for session ${id} failed: ${error.message}`);
18
+ continue;
19
+ }
20
+ if (!row)
21
+ continue;
22
+ const frame = `event: session\ndata: ${JSON.stringify(row)}\n\n`;
23
+ for (const res of clients)
24
+ res.write(frame);
25
+ }
26
+ };
27
+ const heartbeat = setInterval(() => {
28
+ for (const res of clients)
29
+ res.write(": ping\n\n");
30
+ }, heartbeatMs);
31
+ heartbeat.unref();
32
+ return {
33
+ handler(req, res) {
34
+ res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store", Connection: "keep-alive", "X-Accel-Buffering": "no" });
35
+ res.write(": connected\n\n");
36
+ clients.add(res);
37
+ req.on("close", () => clients.delete(res));
38
+ },
39
+ notify(sessionId) {
40
+ pending.add(sessionId);
41
+ if (!scheduled) {
42
+ scheduled = true;
43
+ setImmediate(flush);
44
+ }
45
+ },
46
+ clientCount: () => clients.size,
47
+ close() {
48
+ clearInterval(heartbeat);
49
+ for (const res of clients)
50
+ res.end();
51
+ clients.clear();
52
+ pending.clear();
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,32 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import express from "express";
5
+ import { DASH_COOKIE, dashCookieValue, deny, safeEqual } from "./security.js";
6
+ export function defaultDashboardDir() {
7
+ return fileURLToPath(new URL("../dashboard/", import.meta.url));
8
+ }
9
+ /**
10
+ * `GET /?token=<token>` trades the bearer token for the HttpOnly dashboard cookie and redirects to `/`;
11
+ * `GET /` serves index.html uncached; `/assets/*` serves the built bundle.
12
+ */
13
+ export function registerStatic(app, opts) {
14
+ const dir = opts.dir ?? defaultDashboardDir();
15
+ const cookie = dashCookieValue(opts.token);
16
+ app.get("/", (req, res) => {
17
+ const presented = req.query.token;
18
+ if (presented !== undefined) {
19
+ if (typeof presented !== "string" || !safeEqual(presented, opts.token))
20
+ return deny(res, 401, "unauthorized");
21
+ res.setHeader("Set-Cookie", `${DASH_COOKIE}=${cookie}; HttpOnly; SameSite=Strict; Path=/`);
22
+ res.setHeader("Cache-Control", "no-store");
23
+ return res.redirect(302, "/");
24
+ }
25
+ const index = join(dir, "index.html");
26
+ if (!existsSync(index))
27
+ return deny(res, 503, "dashboard_not_built", { hint: "run npm run build in the gisool package" });
28
+ res.setHeader("Cache-Control", "no-store");
29
+ res.sendFile(index);
30
+ });
31
+ app.use("/assets", express.static(join(dir, "assets"), { index: false, fallthrough: false }));
32
+ }
@@ -0,0 +1,146 @@
1
+ // Ported from server/src/index.ts @48e7955
2
+ import { parse as parseYaml } from "yaml";
3
+ export const BODY_HEAD_MAX = 600;
4
+ /** Splits SKILL.md into YAML frontmatter (object) and body; frontmatter is null when absent or invalid. */
5
+ export function splitSkillMarkdown(input) {
6
+ const markdown = input.replace(/\r\n/g, "\n");
7
+ if (!markdown.startsWith("---\n"))
8
+ return { frontmatter: null, body: markdown };
9
+ const end = markdown.indexOf("\n---", 4);
10
+ if (end === -1)
11
+ return { frontmatter: null, body: markdown };
12
+ const afterDelimiter = markdown.slice(end + 4, end + 5);
13
+ if (afterDelimiter !== "" && afterDelimiter !== "\n")
14
+ return { frontmatter: null, body: markdown };
15
+ const body = markdown.slice(end + 5);
16
+ let parsed;
17
+ try {
18
+ parsed = parseYaml(markdown.slice(4, end));
19
+ }
20
+ catch {
21
+ return { frontmatter: null, body };
22
+ }
23
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
24
+ return { frontmatter: null, body };
25
+ return { frontmatter: parsed, body };
26
+ }
27
+ /** Accepts a YAML list of strings or a comma-separated string; anything else yields []. */
28
+ export function stringList(value) {
29
+ if (Array.isArray(value))
30
+ return value.filter((item) => typeof item === "string").map((s) => s.trim()).filter(Boolean);
31
+ if (typeof value === "string")
32
+ return value.split(",").map((s) => s.trim()).filter(Boolean);
33
+ return [];
34
+ }
35
+ function explicitGroup(value) {
36
+ if (typeof value === "string" && value.trim())
37
+ return { id: value.trim() };
38
+ if (!value || typeof value !== "object" || Array.isArray(value))
39
+ return undefined;
40
+ const record = value;
41
+ if (typeof record.id !== "string" || !record.id.trim())
42
+ return undefined;
43
+ const stage = typeof record.stage === "string" && record.stage.trim() ? record.stage.trim() : typeof record.stage === "number" ? String(record.stage) : undefined;
44
+ return stage === undefined ? { id: record.id.trim() } : { id: record.id.trim(), stage };
45
+ }
46
+ /** First non-heading paragraph of the body, whitespace-collapsed, at most BODY_HEAD_MAX chars. */
47
+ export function bodyHead(body) {
48
+ let inFence = false;
49
+ const paragraphs = [];
50
+ let current = [];
51
+ for (const line of body.split("\n")) {
52
+ if (/^\s*(```|~~~)/.test(line)) {
53
+ inFence = !inFence;
54
+ if (current.length) {
55
+ paragraphs.push(current.join(" "));
56
+ current = [];
57
+ }
58
+ continue;
59
+ }
60
+ if (inFence)
61
+ continue;
62
+ if (!line.trim()) {
63
+ if (current.length) {
64
+ paragraphs.push(current.join(" "));
65
+ current = [];
66
+ }
67
+ continue;
68
+ }
69
+ if (/^\s{0,3}#/.test(line)) {
70
+ if (current.length) {
71
+ paragraphs.push(current.join(" "));
72
+ current = [];
73
+ }
74
+ continue;
75
+ }
76
+ current.push(line.trim());
77
+ if (current.join(" ").length > BODY_HEAD_MAX)
78
+ break;
79
+ }
80
+ if (current.length)
81
+ paragraphs.push(current.join(" "));
82
+ const first = paragraphs.find((p) => p.trim()) ?? "";
83
+ return first.replace(/\s+/g, " ").trim().slice(0, BODY_HEAD_MAX);
84
+ }
85
+ /** Extracts the routing fields; returns null when name or description is missing. */
86
+ export function extractFields(frontmatter, body) {
87
+ const { name, description } = frontmatter;
88
+ if (typeof name !== "string" || !name || typeof description !== "string" || !description)
89
+ return null;
90
+ const whenRaw = frontmatter.when_to_use ?? frontmatter["when-to-use"];
91
+ const fields = {
92
+ name,
93
+ description,
94
+ tags: stringList(frontmatter.tags),
95
+ examples: stringList(frontmatter.examples),
96
+ aliasesKo: stringList(frontmatter.aliases_ko),
97
+ invocation: frontmatter["disable-model-invocation"] === true || frontmatter["disable-model-invocation"] === "true" ? "explicit" : "automatic",
98
+ bodyHead: bodyHead(body),
99
+ };
100
+ if (typeof whenRaw === "string" && whenRaw.trim())
101
+ fields.whenToUse = whenRaw.trim();
102
+ const group = explicitGroup(frontmatter.group);
103
+ if (group)
104
+ fields.group = group;
105
+ return fields;
106
+ }
107
+ /** Family id derived from a name: the prefix before the first "-" (the whole name when there is none). */
108
+ export function familyPrefix(name) {
109
+ const index = name.indexOf("-");
110
+ return index === -1 ? name : name.slice(0, index);
111
+ }
112
+ /**
113
+ * Assigns derived groups (prefix families with >= 2 distinct names) to entries lacking an explicit group,
114
+ * and returns every family: explicit groups plus derived prefix families, uris sorted.
115
+ */
116
+ export function applyFamilies(entries, explicit) {
117
+ const namesByPrefix = new Map();
118
+ for (const entry of entries) {
119
+ if (explicit.get(entry.uri))
120
+ continue;
121
+ const prefix = familyPrefix(entry.name);
122
+ if (!namesByPrefix.has(prefix))
123
+ namesByPrefix.set(prefix, new Set());
124
+ namesByPrefix.get(prefix).add(entry.name);
125
+ }
126
+ const families = new Map();
127
+ for (const entry of entries) {
128
+ const group = explicit.get(entry.uri);
129
+ if (group) {
130
+ entry.group = group;
131
+ }
132
+ else {
133
+ const prefix = familyPrefix(entry.name);
134
+ if ((namesByPrefix.get(prefix)?.size ?? 0) >= 2)
135
+ entry.group = { id: prefix };
136
+ else
137
+ delete entry.group;
138
+ }
139
+ if (entry.group) {
140
+ if (!families.has(entry.group.id))
141
+ families.set(entry.group.id, []);
142
+ families.get(entry.group.id).push(entry.uri);
143
+ }
144
+ }
145
+ return [...families.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([id, uris]) => ({ id, uris: uris.sort() }));
146
+ }