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.
- package/LICENSE +22 -0
- package/README.md +136 -2
- package/dist/api/routes.js +166 -0
- package/dist/api/security.js +86 -0
- package/dist/api/sse.js +55 -0
- package/dist/api/static.js +32 -0
- package/dist/catalog/fields.js +146 -0
- package/dist/catalog/index.js +133 -0
- package/dist/catalog/scanner.js +185 -0
- package/dist/catalog/uri.js +41 -0
- package/dist/catalog/watcher.js +59 -0
- package/dist/cli/config-writer.js +255 -0
- package/dist/cli/doctor.js +197 -0
- package/dist/cli/eval.js +64 -0
- package/dist/cli/export-import.js +66 -0
- package/dist/cli/init.js +271 -0
- package/dist/cli/main.js +165 -0
- package/dist/cli/open.js +24 -0
- package/dist/cli/serve.js +144 -0
- package/dist/config.js +78 -0
- package/dist/dashboard/assets/app.js +256 -0
- package/dist/dashboard/assets/chunk-4U4GNPH4.js +17 -0
- package/dist/dashboard/assets/chunk-5DFWNJC2.js +1 -0
- package/dist/dashboard/assets/chunk-JPIEF5XO.js +17 -0
- package/dist/dashboard/assets/chunk-ZBUKVJOI.js +1 -0
- package/dist/dashboard/assets/styles.css +487 -0
- package/dist/dashboard/index.html +15 -0
- package/dist/ingest/attribution.js +55 -0
- package/dist/ingest/hooks.js +168 -0
- package/dist/ingest/mcp-recorder.js +41 -0
- package/dist/ingest/otlp.js +160 -0
- package/dist/ingest/pipeline.js +100 -0
- package/dist/ingest/redact.js +36 -0
- package/dist/ingest/runtimes.js +25 -0
- package/dist/mcp/http.js +84 -0
- package/dist/mcp/skills-extension.js +35 -0
- package/dist/mcp/stdio-proxy.js +53 -0
- package/dist/mcp/tools.js +164 -0
- package/dist/packs/claude-code.js +157 -0
- package/dist/packs/codex.js +148 -0
- package/dist/packs/cursor.js +95 -0
- package/dist/packs/gajae.js +7 -0
- package/dist/packs/gemini.js +88 -0
- package/dist/packs/json-config.js +51 -0
- package/dist/packs/omo.js +149 -0
- package/dist/packs/openclaw.js +119 -0
- package/dist/packs/registry.js +13 -0
- package/dist/search/fts.js +61 -0
- package/dist/search/fusion.js +50 -0
- package/dist/search/guidance.js +7 -0
- package/dist/search/index.js +74 -0
- package/dist/search/lexical.js +69 -0
- package/dist/server.js +216 -0
- package/dist/store/db.js +74 -0
- package/dist/store/export.js +199 -0
- package/dist/store/index.js +13 -0
- package/dist/store/queries.js +458 -0
- package/dist/store/schema.sql +96 -0
- package/dist/store/writes.js +172 -0
- package/dist/types.js +1 -0
- package/dist/version.js +19 -0
- package/package.json +46 -4
- package/packs/claude-code/README.md +5 -0
- package/packs/claude-code/settings.template.json +24 -0
- package/packs/codex/README.md +5 -0
- package/packs/codex/config.template.toml +11 -0
- package/packs/cursor/hooks.example.json +8 -0
- package/packs/cursor/mcp.example.json +8 -0
- package/packs/gajae/extension/discovery.md +5 -0
- package/packs/gajae/extension/index.js +44 -0
- package/packs/gemini/settings.example.json +16 -0
- package/packs/hook-post.mjs +29 -0
- package/packs/omo/extension/discovery.md +5 -0
- package/packs/omo/extension/index.js +44 -0
- package/packs/openclaw/.codex-plugin/plugin.json +11 -0
- package/packs/openclaw/.mcp.json +12 -0
- package/packs/openclaw/gisool.json +1 -0
- package/packs/openclaw/hooks/gisool-discovery/HOOK.md +8 -0
- package/packs/openclaw/hooks/gisool-discovery/handler.js +27 -0
- package/packs/openclaw/index.mjs +47 -0
- package/packs/openclaw/openclaw.plugin.json +22 -0
- package/packs/openclaw/package.json +11 -0
- package/src/store/schema.sql +96 -0
- 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
|
-
#
|
|
1
|
+
# gisool
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|
package/dist/api/sse.js
ADDED
|
@@ -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
|
+
}
|