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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isObject, isRemoteEntry, noop, planJsonFile, readJsonConfig, refuse } from "./json-config.js";
|
|
4
|
+
// Hook shape confirmed against https://geminicli.com/docs/hooks/ : settings.json "hooks" maps an event name to groups
|
|
5
|
+
// of { matcher?, hooks: [{ type: "command", name, command, timeout }] }; timeout is in milliseconds. BeforeAgent is a
|
|
6
|
+
// lifecycle event (exact-string matcher, so none is written); AfterTool matchers are regular expressions over tool names.
|
|
7
|
+
const HOOK_NAME = "gisool-capture";
|
|
8
|
+
const HOOK_TIMEOUT_MS = 5000;
|
|
9
|
+
const HOOK_EVENTS = [{ event: "BeforeAgent" }, { event: "AfterTool", matcher: "activate_skill" }];
|
|
10
|
+
const URL_KEYS = ["httpUrl", "url"];
|
|
11
|
+
function localMcpUrl(port) {
|
|
12
|
+
return `http://127.0.0.1:${port}/mcp`;
|
|
13
|
+
}
|
|
14
|
+
function hookCommand(ctx) {
|
|
15
|
+
return `"${ctx.nodePath}" "${join(ctx.packageRoot, "packs", "hook-post.mjs")}" gemini`;
|
|
16
|
+
}
|
|
17
|
+
function isOurHookGroup(group) {
|
|
18
|
+
return (isObject(group) &&
|
|
19
|
+
Array.isArray(group.hooks) &&
|
|
20
|
+
group.hooks.some((h) => isObject(h) && typeof h.command === "string" && h.command.endsWith('hook-post.mjs" gemini')));
|
|
21
|
+
}
|
|
22
|
+
export const geminiPack = {
|
|
23
|
+
id: "gemini",
|
|
24
|
+
async detect({ home }) {
|
|
25
|
+
const dir = join(home, ".gemini");
|
|
26
|
+
const installed = existsSync(dir);
|
|
27
|
+
const servers = readJsonConfig(join(dir, "settings.json")).obj.mcpServers;
|
|
28
|
+
return {
|
|
29
|
+
installed,
|
|
30
|
+
evidence: installed ? [dir] : [],
|
|
31
|
+
remoteGisulRegistered: isObject(servers) && isRemoteEntry(servers.gisul, URL_KEYS),
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
async plan(ctx) {
|
|
35
|
+
const dir = join(ctx.home, ".gemini");
|
|
36
|
+
if (!existsSync(dir))
|
|
37
|
+
return noop("~/.gemini not found; Gemini CLI does not appear to be installed, nothing planned.");
|
|
38
|
+
const path = join(dir, "settings.json");
|
|
39
|
+
const current = readJsonConfig(path);
|
|
40
|
+
if (current.error)
|
|
41
|
+
return refuse(path, "unparseable_config", current.error);
|
|
42
|
+
const next = structuredClone(current.obj);
|
|
43
|
+
const servers = next.mcpServers ?? {};
|
|
44
|
+
const hooks = next.hooks ?? {};
|
|
45
|
+
if (!isObject(servers) || !isObject(hooks))
|
|
46
|
+
return refuse(path, "invalid_shape", "mcpServers and hooks must be JSON objects");
|
|
47
|
+
const notes = [];
|
|
48
|
+
if (ctx.disableRemote && isRemoteEntry(servers.gisul, URL_KEYS)) {
|
|
49
|
+
notes.push("Remote gisul MCP entry left enabled: Gemini's disable key is unverified, so disable it manually.");
|
|
50
|
+
}
|
|
51
|
+
servers.gisool = { httpUrl: localMcpUrl(ctx.port), headers: { Authorization: `Bearer ${ctx.token}` } };
|
|
52
|
+
next.mcpServers = servers;
|
|
53
|
+
for (const { event, matcher } of HOOK_EVENTS) {
|
|
54
|
+
const groups = hooks[event] ?? [];
|
|
55
|
+
if (!Array.isArray(groups))
|
|
56
|
+
return refuse(path, "invalid_shape", `hooks.${event} must be an array`);
|
|
57
|
+
const ours = {
|
|
58
|
+
...(matcher ? { matcher } : {}),
|
|
59
|
+
hooks: [{ type: "command", name: HOOK_NAME, command: hookCommand(ctx), timeout: HOOK_TIMEOUT_MS }],
|
|
60
|
+
};
|
|
61
|
+
hooks[event] = [...groups.filter((group) => !isOurHookGroup(group)), ours];
|
|
62
|
+
}
|
|
63
|
+
next.hooks = hooks;
|
|
64
|
+
const file = planJsonFile(path, current, next);
|
|
65
|
+
notes.push("Restart Gemini CLI to load the gisool MCP server and capture hooks.");
|
|
66
|
+
return { files: file ? [file] : [], commands: [], notes, conflicts: [] };
|
|
67
|
+
},
|
|
68
|
+
async doctorChecks({ home, port }) {
|
|
69
|
+
const current = readJsonConfig(join(home, ".gemini", "settings.json"));
|
|
70
|
+
if (current.error)
|
|
71
|
+
return [{ name: "gemini settings.json", ok: false, detail: current.error }];
|
|
72
|
+
const servers = isObject(current.obj.mcpServers) ? current.obj.mcpServers : {};
|
|
73
|
+
const entry = isObject(servers.gisool) ? servers.gisool : {};
|
|
74
|
+
const hooks = isObject(current.obj.hooks) ? current.obj.hooks : {};
|
|
75
|
+
const hooksPresent = HOOK_EVENTS.every(({ event }) => {
|
|
76
|
+
const groups = hooks[event];
|
|
77
|
+
return Array.isArray(groups) && groups.some(isOurHookGroup);
|
|
78
|
+
});
|
|
79
|
+
return [
|
|
80
|
+
{ name: "gemini MCP gisool", ok: entry.httpUrl === localMcpUrl(port), detail: `httpUrl ${String(entry.httpUrl ?? "missing")}` },
|
|
81
|
+
{
|
|
82
|
+
name: "gemini capture hooks",
|
|
83
|
+
ok: hooksPresent,
|
|
84
|
+
detail: hooksPresent ? "BeforeAgent and AfterTool present" : "capture hooks missing; rerun gisool init",
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
},
|
|
88
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
3
|
+
export function isObject(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
export function readJsonConfig(path) {
|
|
7
|
+
if (!existsSync(path))
|
|
8
|
+
return { before: null, obj: {} };
|
|
9
|
+
const before = readFileSync(path, "utf8");
|
|
10
|
+
if (before.trim() === "")
|
|
11
|
+
return { before, obj: {} };
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(before);
|
|
14
|
+
if (isObject(parsed))
|
|
15
|
+
return { before, obj: parsed };
|
|
16
|
+
return { before, obj: {}, error: "top-level value is not a JSON object" };
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
return { before, obj: {}, error: `not valid JSON: ${err.message}` };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function fileMode(path) {
|
|
23
|
+
return existsSync(path) ? statSync(path).mode & 0o777 : 0o600;
|
|
24
|
+
}
|
|
25
|
+
export function planJsonFile(path, current, next) {
|
|
26
|
+
const after = `${JSON.stringify(next, null, 2)}\n`;
|
|
27
|
+
if (after === current.before)
|
|
28
|
+
return null;
|
|
29
|
+
return { path, before: current.before, after, mode: fileMode(path) };
|
|
30
|
+
}
|
|
31
|
+
export function refuse(path, code, detail) {
|
|
32
|
+
return { files: [], commands: [], notes: [], conflicts: [{ path, code, detail }] };
|
|
33
|
+
}
|
|
34
|
+
export function noop(note) {
|
|
35
|
+
return { files: [], commands: [], notes: [note], conflicts: [] };
|
|
36
|
+
}
|
|
37
|
+
export function isRemoteEntry(entry, urlKeys) {
|
|
38
|
+
if (!isObject(entry))
|
|
39
|
+
return false;
|
|
40
|
+
return urlKeys.some((key) => {
|
|
41
|
+
const raw = entry[key];
|
|
42
|
+
if (typeof raw !== "string")
|
|
43
|
+
return false;
|
|
44
|
+
try {
|
|
45
|
+
return !LOOPBACK_HOSTS.has(new URL(raw).hostname);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
const EXT_FILES = ["index.js", "discovery.md"];
|
|
4
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
5
|
+
function readOrNull(path) {
|
|
6
|
+
try {
|
|
7
|
+
if (!lstatSync(path).isFile())
|
|
8
|
+
return null;
|
|
9
|
+
return readFileSync(path, "utf8");
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function resolveOmoAgentDir(home, env) {
|
|
16
|
+
for (const name of ["OMO_CODING_AGENT_DIR", "SENPI_CODING_AGENT_DIR", "PI_CODING_AGENT_DIR"]) {
|
|
17
|
+
const v = env[name]?.trim();
|
|
18
|
+
if (v)
|
|
19
|
+
return resolve(v);
|
|
20
|
+
}
|
|
21
|
+
return join(home, ".omo", "agent");
|
|
22
|
+
}
|
|
23
|
+
export function resolveGajaeAgentDir(home, env) {
|
|
24
|
+
for (const name of ["GJC_CODING_AGENT_DIR", "PI_CODING_AGENT_DIR"]) {
|
|
25
|
+
const v = env[name]?.trim();
|
|
26
|
+
if (v)
|
|
27
|
+
return resolve(v);
|
|
28
|
+
}
|
|
29
|
+
const configDir = env.GJC_CONFIG_DIR?.trim() || env.PI_CONFIG_DIR?.trim() || ".gjc";
|
|
30
|
+
if (isAbsolute(configDir) || configDir.split(/[\\/]/).some(part => ["", ".", ".."].includes(part))) {
|
|
31
|
+
throw new Error("Use GJC_CODING_AGENT_DIR for an explicit Gajae profile path");
|
|
32
|
+
}
|
|
33
|
+
return join(home, configDir, "agent");
|
|
34
|
+
}
|
|
35
|
+
function isLoopbackUrl(raw) {
|
|
36
|
+
try {
|
|
37
|
+
const host = new URL(raw).hostname.replace(/^\[|\]$/g, "");
|
|
38
|
+
return host === "localhost" || host === "::1" || host.startsWith("127.");
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function isRemoteGisul(server) {
|
|
45
|
+
if (!isObject(server))
|
|
46
|
+
return false;
|
|
47
|
+
if (typeof server.url === "string")
|
|
48
|
+
return !isLoopbackUrl(server.url);
|
|
49
|
+
const argv = [server.command, ...(Array.isArray(server.args) ? server.args : [])];
|
|
50
|
+
return argv.some(a => typeof a === "string" && a.includes("mcp-remote"));
|
|
51
|
+
}
|
|
52
|
+
function parseConfig(text) {
|
|
53
|
+
if (text === null)
|
|
54
|
+
return {};
|
|
55
|
+
try {
|
|
56
|
+
const v = JSON.parse(text);
|
|
57
|
+
return isObject(v) && (v.mcpServers === undefined || isObject(v.mcpServers)) ? v : undefined;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function createPiPack(spec) {
|
|
64
|
+
const extDir = (agentDir) => join(agentDir, "extensions", spec.extensionName);
|
|
65
|
+
return {
|
|
66
|
+
id: spec.id,
|
|
67
|
+
async detect({ home, env }) {
|
|
68
|
+
let agentDir;
|
|
69
|
+
try {
|
|
70
|
+
agentDir = spec.agentDir(home, env);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return { installed: false, evidence: [], remoteGisulRegistered: false };
|
|
74
|
+
}
|
|
75
|
+
const evidence = [agentDir, join(home, spec.installMarker)].filter(p => existsSync(p));
|
|
76
|
+
const cfg = parseConfig(readOrNull(join(agentDir, "mcp.json")));
|
|
77
|
+
const servers = isObject(cfg?.mcpServers) ? cfg.mcpServers : {};
|
|
78
|
+
return { installed: evidence.length > 0, evidence, remoteGisulRegistered: isRemoteGisul(servers.gisul) };
|
|
79
|
+
},
|
|
80
|
+
async plan(ctx) {
|
|
81
|
+
const plan = { files: [], commands: [], notes: [], conflicts: [] };
|
|
82
|
+
let agentDir;
|
|
83
|
+
try {
|
|
84
|
+
agentDir = spec.agentDir(ctx.home, ctx.env);
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
plan.conflicts.push({ path: ctx.home, code: "agent_dir_unresolved", detail: e.message });
|
|
88
|
+
return plan;
|
|
89
|
+
}
|
|
90
|
+
const mcpPath = join(agentDir, "mcp.json");
|
|
91
|
+
const before = readOrNull(mcpPath);
|
|
92
|
+
const cfg = parseConfig(before);
|
|
93
|
+
if (!cfg) {
|
|
94
|
+
plan.conflicts.push({ path: mcpPath, code: "invalid_mcp_config", detail: "mcp.json is not a JSON object with an mcpServers map; left untouched" });
|
|
95
|
+
return plan;
|
|
96
|
+
}
|
|
97
|
+
const servers = { ...(isObject(cfg.mcpServers) ? cfg.mcpServers : {}) };
|
|
98
|
+
servers.gisool = { type: "http", url: `http://127.0.0.1:${ctx.port}/mcp`, headers: { Authorization: `Bearer ${ctx.token}` } };
|
|
99
|
+
if (ctx.disableRemote && isRemoteGisul(servers.gisul)) {
|
|
100
|
+
servers.gisul = { ...servers.gisul, enabled: false };
|
|
101
|
+
plan.notes.push("remote gisul entry set to enabled:false (original definition kept)");
|
|
102
|
+
}
|
|
103
|
+
const mcpAfter = JSON.stringify({ ...cfg, mcpServers: servers }, null, 2) + "\n";
|
|
104
|
+
plan.files.push({ path: mcpPath, before, after: mcpAfter, mode: 0o600 });
|
|
105
|
+
const dir = extDir(agentDir);
|
|
106
|
+
const source = join(ctx.packageRoot, "packs", spec.id, "extension");
|
|
107
|
+
const add = (path, after) => {
|
|
108
|
+
const f = { path, before: readOrNull(path), after, mode: 0o644 };
|
|
109
|
+
plan.files.push(f);
|
|
110
|
+
};
|
|
111
|
+
for (const name of EXT_FILES)
|
|
112
|
+
add(join(dir, name), readFileSync(join(source, name), "utf8"));
|
|
113
|
+
add(join(dir, "config.json"), JSON.stringify({ port: ctx.port }, null, 2) + "\n");
|
|
114
|
+
plan.notes.push(`extension forwarder installed at ${dir}; it reads the token from $GISOOL_HOME/token at load`);
|
|
115
|
+
return plan;
|
|
116
|
+
},
|
|
117
|
+
async doctorChecks({ home, env, port }) {
|
|
118
|
+
const checks = [];
|
|
119
|
+
let agentDir;
|
|
120
|
+
try {
|
|
121
|
+
agentDir = spec.agentDir(home, env);
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
return [{ name: `${spec.id} agent dir`, ok: false, detail: e.message }];
|
|
125
|
+
}
|
|
126
|
+
const cfg = parseConfig(readOrNull(join(agentDir, "mcp.json")));
|
|
127
|
+
const entry = isObject(cfg?.mcpServers) ? cfg.mcpServers.gisool : undefined;
|
|
128
|
+
const want = `http://127.0.0.1:${port}/mcp`;
|
|
129
|
+
const url = isObject(entry) ? entry.url : undefined;
|
|
130
|
+
checks.push({ name: `${spec.id} mcp.json gisool`, ok: url === want, detail: url === want ? want : `expected ${want}, found ${String(url)}` });
|
|
131
|
+
const idx = join(extDir(agentDir), "index.js");
|
|
132
|
+
checks.push({ name: `${spec.id} extension forwarder`, ok: existsSync(idx), detail: idx });
|
|
133
|
+
const cfgPath = join(extDir(agentDir), "config.json");
|
|
134
|
+
let cfgPort;
|
|
135
|
+
try {
|
|
136
|
+
cfgPort = JSON.parse(readOrNull(cfgPath) ?? "{}").port;
|
|
137
|
+
}
|
|
138
|
+
catch { /* reported below */ }
|
|
139
|
+
checks.push({ name: `${spec.id} extension port`, ok: cfgPort === port, detail: cfgPort === port ? String(port) : `expected ${port}, found ${String(cfgPort)}` });
|
|
140
|
+
return checks;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export const omoPack = createPiPack({
|
|
145
|
+
id: "omo",
|
|
146
|
+
agentDir: resolveOmoAgentDir,
|
|
147
|
+
extensionName: "gisool",
|
|
148
|
+
installMarker: ".omo",
|
|
149
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
const CONFIG_NOTE = "Set plugins.entries.gisool.hooks.allowConversationAccess: true in the OpenClaw config (openclaw.json); without it before_agent_run is not delivered.";
|
|
4
|
+
const TEMPLATES = new Set([".mcp.json", "openclaw.plugin.json", "gisool.json"]);
|
|
5
|
+
function gisoolHome(env, home) {
|
|
6
|
+
return env.GISOOL_HOME || join(home, ".gisool");
|
|
7
|
+
}
|
|
8
|
+
function openclawConfigPath(env, home) {
|
|
9
|
+
return env.OPENCLAW_CONFIG_PATH || join(env.OPENCLAW_STATE_DIR || join(home, ".openclaw"), "openclaw.json");
|
|
10
|
+
}
|
|
11
|
+
function walk(dir) {
|
|
12
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)]));
|
|
13
|
+
}
|
|
14
|
+
function readOrNull(path) {
|
|
15
|
+
try {
|
|
16
|
+
return readFileSync(path, "utf8");
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function fill(node, vars) {
|
|
23
|
+
if (typeof node === "string")
|
|
24
|
+
return node in vars ? vars[node] : node;
|
|
25
|
+
if (Array.isArray(node))
|
|
26
|
+
return node.flatMap((n) => (typeof n === "string" && Array.isArray(vars[n]) ? vars[n] : [fill(n, vars)]));
|
|
27
|
+
if (node && typeof node === "object")
|
|
28
|
+
return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, fill(v, vars)]));
|
|
29
|
+
return node;
|
|
30
|
+
}
|
|
31
|
+
/** Tolerant read of an openclaw.json (JSON5-ish): strips comments and trailing commas, falls back to null. */
|
|
32
|
+
function parseConfig(text) {
|
|
33
|
+
const stripped = text.replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (_m, s) => s ?? "").replace(/,(\s*[}\]])/g, "$1");
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(stripped);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
42
|
+
const pluginEntries = (cfg) => (isRecord(cfg.plugins) && isRecord(cfg.plugins.entries) ? cfg.plugins.entries : undefined);
|
|
43
|
+
function hasConversationAccess(text) {
|
|
44
|
+
const cfg = parseConfig(text);
|
|
45
|
+
if (isRecord(cfg))
|
|
46
|
+
return pluginEntries(cfg)?.gisool?.hooks?.allowConversationAccess === true;
|
|
47
|
+
return /allowConversationAccess["']?\s*:\s*true/.test(text);
|
|
48
|
+
}
|
|
49
|
+
export function bundleDir(env, home) {
|
|
50
|
+
return join(gisoolHome(env, home), "packs", "openclaw");
|
|
51
|
+
}
|
|
52
|
+
export const openclawPack = {
|
|
53
|
+
id: "openclaw",
|
|
54
|
+
async detect({ home, env }) {
|
|
55
|
+
const state = env.OPENCLAW_STATE_DIR || join(home, ".openclaw");
|
|
56
|
+
const evidence = [];
|
|
57
|
+
if (existsSync(state))
|
|
58
|
+
evidence.push(state);
|
|
59
|
+
if (existsSync(openclawConfigPath(env, home)))
|
|
60
|
+
evidence.push(openclawConfigPath(env, home));
|
|
61
|
+
const remote = [join(state, "gisul"), join(state, "plugins", "gisul"), join(state, "extensions", "gisul")].find(existsSync);
|
|
62
|
+
const cfgText = readOrNull(openclawConfigPath(env, home));
|
|
63
|
+
const cfg = cfgText ? parseConfig(cfgText) : null;
|
|
64
|
+
const entries = isRecord(cfg) ? pluginEntries(cfg) : undefined;
|
|
65
|
+
const remoteGisulRegistered = Boolean(remote) || Boolean(entries?.gisul || entries?.["gisul-openclaw"]);
|
|
66
|
+
if (remote)
|
|
67
|
+
evidence.push(remote);
|
|
68
|
+
return { installed: evidence.length > 0, evidence, remoteGisulRegistered };
|
|
69
|
+
},
|
|
70
|
+
async plan(ctx) {
|
|
71
|
+
const src = join(ctx.packageRoot, "packs", "openclaw");
|
|
72
|
+
const dest = bundleDir(ctx.env, ctx.home);
|
|
73
|
+
const vars = {
|
|
74
|
+
__COMMAND__: ctx.nodePath,
|
|
75
|
+
__ARGS__: [join(ctx.packageRoot, "dist", "cli", "main.js"), "mcp-stdio"],
|
|
76
|
+
__GISOOL_HOME__: gisoolHome(ctx.env, ctx.home),
|
|
77
|
+
__PORT__: ctx.port,
|
|
78
|
+
};
|
|
79
|
+
const files = walk(src)
|
|
80
|
+
.sort()
|
|
81
|
+
.map((abs) => {
|
|
82
|
+
const rel = relative(src, abs);
|
|
83
|
+
const raw = readFileSync(abs, "utf8");
|
|
84
|
+
const after = TEMPLATES.has(rel) ? JSON.stringify(fill(JSON.parse(raw), vars), null, 2) + "\n" : raw;
|
|
85
|
+
const path = join(dest, rel);
|
|
86
|
+
return { path, before: readOrNull(path), after, mode: statSync(abs).mode & 0o777 };
|
|
87
|
+
});
|
|
88
|
+
const notes = [
|
|
89
|
+
`Bundle files: ${files.map((f) => relative(dest, f.path)).join(", ")}`,
|
|
90
|
+
CONFIG_NOTE,
|
|
91
|
+
"Restart the OpenClaw Gateway after installing so the plugin and its hooks load.",
|
|
92
|
+
];
|
|
93
|
+
const commands = [];
|
|
94
|
+
const install = ["openclaw", "plugins", "install", dest];
|
|
95
|
+
const uninstall = ["openclaw", "plugins", "uninstall", "gisool"];
|
|
96
|
+
if (ctx.env.GISOOL_NO_CLI)
|
|
97
|
+
notes.push(`GISOOL_NO_CLI set: run manually: ${install.join(" ")} (undo: ${uninstall.join(" ")})`);
|
|
98
|
+
else
|
|
99
|
+
commands.push({ argv: install, description: "Install the gisool capture plugin into OpenClaw (restart the OpenClaw Gateway afterwards)", undo: { argv: uninstall, description: "Uninstall the gisool OpenClaw plugin" } });
|
|
100
|
+
if (ctx.disableRemote)
|
|
101
|
+
notes.push("Remote gisul OpenClaw bundle is not removed automatically. To disable it run: openclaw plugins disable gisul-openclaw; to remove it run: openclaw plugins uninstall gisul-openclaw");
|
|
102
|
+
return { files, commands, notes, conflicts: [] };
|
|
103
|
+
},
|
|
104
|
+
async doctorChecks({ home, env }) {
|
|
105
|
+
const checks = [];
|
|
106
|
+
const bundle = bundleDir(env, home);
|
|
107
|
+
const bundled = existsSync(join(bundle, "openclaw.plugin.json"));
|
|
108
|
+
checks.push({ name: "openclaw bundle", ok: bundled, detail: bundled ? bundle : `bundle missing at ${bundle}; run gisool init` });
|
|
109
|
+
const cfgPath = openclawConfigPath(env, home);
|
|
110
|
+
const text = readOrNull(cfgPath);
|
|
111
|
+
if (text === null)
|
|
112
|
+
checks.push({ name: "allowConversationAccess", ok: false, detail: `allowConversationAccess missing: no OpenClaw config at ${cfgPath}` });
|
|
113
|
+
else if (hasConversationAccess(text))
|
|
114
|
+
checks.push({ name: "allowConversationAccess", ok: true, detail: cfgPath });
|
|
115
|
+
else
|
|
116
|
+
checks.push({ name: "allowConversationAccess", ok: false, detail: `allowConversationAccess missing: set plugins.entries.gisool.hooks.allowConversationAccess: true in ${cfgPath}` });
|
|
117
|
+
return checks;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { claudeCodePack } from "./claude-code.js";
|
|
2
|
+
import { codexPack } from "./codex.js";
|
|
3
|
+
import { cursorPack } from "./cursor.js";
|
|
4
|
+
import { gajaePack } from "./gajae.js";
|
|
5
|
+
import { geminiPack } from "./gemini.js";
|
|
6
|
+
import { omoPack } from "./omo.js";
|
|
7
|
+
import { openclawPack } from "./openclaw.js";
|
|
8
|
+
/** Every runtime pack gisool ships, in the order init plans and doctor reports them. */
|
|
9
|
+
export const PACKS = [claudeCodePack, codexPack, geminiPack, cursorPack, omoPack, gajaePack, openclawPack];
|
|
10
|
+
export const PACK_IDS = PACKS.map((p) => p.id);
|
|
11
|
+
export function getPack(id) {
|
|
12
|
+
return PACKS.find((p) => p.id === id);
|
|
13
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isStopword, normalize, stem, words } from "./lexical.js";
|
|
2
|
+
export const FTS_MIN_CHARS = 3;
|
|
3
|
+
const length = (s) => Array.from(s).length;
|
|
4
|
+
export function ftsTokens(query) {
|
|
5
|
+
const text = normalize(query);
|
|
6
|
+
if (length(text) < FTS_MIN_CHARS)
|
|
7
|
+
return [];
|
|
8
|
+
const tokens = new Set();
|
|
9
|
+
for (const word of words(text)) {
|
|
10
|
+
if (isStopword(word))
|
|
11
|
+
continue;
|
|
12
|
+
for (const t of [word, stem(word)])
|
|
13
|
+
if (length(t) >= FTS_MIN_CHARS)
|
|
14
|
+
tokens.add(t);
|
|
15
|
+
}
|
|
16
|
+
tokens.add(text);
|
|
17
|
+
return [...tokens];
|
|
18
|
+
}
|
|
19
|
+
export const ftsMatchExpression = (tokens) => tokens.map(t => `"${t.replace(/"/g, '""')}"`).join(" OR ");
|
|
20
|
+
export class FtsIndex {
|
|
21
|
+
db;
|
|
22
|
+
insertStmt;
|
|
23
|
+
deleteStmt;
|
|
24
|
+
searchStmt;
|
|
25
|
+
texts = new Map();
|
|
26
|
+
constructor(db) {
|
|
27
|
+
this.db = db;
|
|
28
|
+
this.insertStmt = db.prepare("INSERT INTO skills_fts(uri, name, description, when_to_use, examples, aliases_ko, body_head) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
|
29
|
+
this.deleteStmt = db.prepare("DELETE FROM skills_fts WHERE uri = ?");
|
|
30
|
+
// bm25 weights follow column order: uri, name, description, when_to_use, examples, aliases_ko, body_head.
|
|
31
|
+
this.searchStmt = db.prepare("SELECT uri, bm25(skills_fts, 0, 8, 2, 3, 5, 6, 1) AS score FROM skills_fts WHERE skills_fts MATCH ? ORDER BY score LIMIT ?");
|
|
32
|
+
}
|
|
33
|
+
clear() {
|
|
34
|
+
this.db.exec("DELETE FROM skills_fts");
|
|
35
|
+
this.texts.clear();
|
|
36
|
+
}
|
|
37
|
+
remove(uris) {
|
|
38
|
+
for (const uri of uris) {
|
|
39
|
+
this.deleteStmt.run(uri);
|
|
40
|
+
this.texts.delete(uri);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
upsert(entries) {
|
|
44
|
+
for (const e of entries) {
|
|
45
|
+
this.deleteStmt.run(e.uri);
|
|
46
|
+
const fields = [e.name, e.description, e.whenToUse ?? "", e.examples.join("\n"), e.aliasesKo.join("\n"), e.bodyHead];
|
|
47
|
+
this.insertStmt.run(e.uri, ...fields);
|
|
48
|
+
this.texts.set(e.uri, normalize(fields.join(" ")));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
search(query, limit = 50) {
|
|
52
|
+
const tokens = ftsTokens(query);
|
|
53
|
+
if (!tokens.length)
|
|
54
|
+
return [];
|
|
55
|
+
const rows = this.searchStmt.all(ftsMatchExpression(tokens), limit);
|
|
56
|
+
return rows.map((row, i) => {
|
|
57
|
+
const text = this.texts.get(row.uri) ?? "";
|
|
58
|
+
return { uri: row.uri, rank: i + 1, bm25: row.score, matched: tokens.filter(t => text.includes(t)) };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const RRF_K = 60;
|
|
2
|
+
// Written by `node local/bench/search.mjs` from calibrate(); `--check` asserts it is current.
|
|
3
|
+
export const DEFAULT_THRESHOLD = 0;
|
|
4
|
+
const MAX_RRF = 2 / (RRF_K + 1);
|
|
5
|
+
const round4 = (n) => Math.round(n * 1e4) / 1e4;
|
|
6
|
+
/** Query terms (from lexical `terms()`) that a candidate's matched list covers; the dashboard shows the same count. */
|
|
7
|
+
export function termCoverage(queryTerms, matched) {
|
|
8
|
+
const set = new Set(matched.map(m => m.normalize("NFKC").toLowerCase()));
|
|
9
|
+
return { hit: queryTerms.filter(t => set.has(t)).length, total: queryTerms.length };
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Fuse both channels by reciprocal rank. Order uses raw RRF; the displayed score is the rank score scaled by the
|
|
13
|
+
* share of query terms the candidate matched, so a top rank on a weak match no longer reads as 1.00.
|
|
14
|
+
* Without `queryTerms` (or for a stopword-only query) coverage is 1 and the score stays rank-only.
|
|
15
|
+
*/
|
|
16
|
+
export function fuse(lexical, fts, queryTerms = []) {
|
|
17
|
+
const byUri = new Map();
|
|
18
|
+
const get = (uri) => {
|
|
19
|
+
let f = byUri.get(uri);
|
|
20
|
+
if (!f)
|
|
21
|
+
byUri.set(uri, f = { uri, score: 0, rrf: 0, anchor: 0, lexScore: 0, matched: [], coverage: 1 });
|
|
22
|
+
return f;
|
|
23
|
+
};
|
|
24
|
+
lexical.forEach((hit, i) => {
|
|
25
|
+
const f = get(hit.uri);
|
|
26
|
+
f.rrf += 1 / (RRF_K + i + 1);
|
|
27
|
+
f.anchor = hit.anchor;
|
|
28
|
+
f.lexScore = hit.score;
|
|
29
|
+
f.matched.push(...hit.matched);
|
|
30
|
+
});
|
|
31
|
+
for (const hit of fts) {
|
|
32
|
+
const f = get(hit.uri);
|
|
33
|
+
f.rrf += 1 / (RRF_K + hit.rank);
|
|
34
|
+
f.matched.push(...hit.matched);
|
|
35
|
+
}
|
|
36
|
+
const fused = [...byUri.values()];
|
|
37
|
+
for (const f of fused) {
|
|
38
|
+
f.matched = [...new Set(f.matched)];
|
|
39
|
+
const { hit, total } = termCoverage(queryTerms, f.matched);
|
|
40
|
+
f.coverage = total ? hit / total : 1;
|
|
41
|
+
// Normalize against the best possible RRF (rank 1 in both lists), scale by term coverage; anchors pin to 1.
|
|
42
|
+
f.score = f.anchor ? 1 : round4(Math.min(1, f.rrf / MAX_RRF) * f.coverage);
|
|
43
|
+
}
|
|
44
|
+
return fused.sort((a, b) => b.anchor - a.anchor || b.rrf - a.rrf || b.lexScore - a.lexScore || (a.uri < b.uri ? -1 : a.uri > b.uri ? 1 : 0));
|
|
45
|
+
}
|
|
46
|
+
/** Smallest threshold that makes every negative abstain: just above the best negative score, or 0 when none hit. */
|
|
47
|
+
export function calibrate(negativeBestScores, margin = 0.01) {
|
|
48
|
+
const hits = negativeBestScores.filter(s => s > 0);
|
|
49
|
+
return hits.length ? round4(Math.min(1, Math.max(...hits) + margin)) : 0;
|
|
50
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const SEARCH_TOOL_GUIDANCE = "Rewrite the user's task as a short English intent sentence using skill vocabulary (Korean is accepted). Do not pass skill bodies. Use mode=discovery.";
|
|
2
|
+
export const ABSTAIN_HINTS = {
|
|
3
|
+
empty_query: "Describe the task as an intent sentence",
|
|
4
|
+
no_overlap: "No skill shares vocabulary with this query; rewrite it with skill vocabulary or continue without a skill",
|
|
5
|
+
below_threshold: "Matches are too weak; rephrase as a short intent sentence or continue without a skill",
|
|
6
|
+
explicit_only: "Only explicit-invocation skills matched; ask the user or search with mode=explicit",
|
|
7
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
import { FtsIndex } from "./fts.js";
|
|
4
|
+
import { DEFAULT_THRESHOLD, fuse } from "./fusion.js";
|
|
5
|
+
import { ABSTAIN_HINTS } from "./guidance.js";
|
|
6
|
+
import { buildLexical, normalize, terms } from "./lexical.js";
|
|
7
|
+
export { DEFAULT_THRESHOLD, RRF_K, calibrate, fuse, termCoverage } from "./fusion.js";
|
|
8
|
+
export { SEARCH_TOOL_GUIDANCE, ABSTAIN_HINTS } from "./guidance.js";
|
|
9
|
+
export { normalize, terms } from "./lexical.js";
|
|
10
|
+
export { ftsTokens } from "./fts.js";
|
|
11
|
+
export function createSearchIndex(db) {
|
|
12
|
+
const entries = new Map();
|
|
13
|
+
const fts = new FtsIndex(db);
|
|
14
|
+
let lexical = buildLexical([]);
|
|
15
|
+
const thresholdStmt = db.prepare("SELECT value FROM meta WHERE key = 'search_threshold'");
|
|
16
|
+
const threshold = () => {
|
|
17
|
+
const row = thresholdStmt.get();
|
|
18
|
+
const value = row?.value == null ? NaN : Number(row.value);
|
|
19
|
+
return Number.isFinite(value) ? value : DEFAULT_THRESHOLD;
|
|
20
|
+
};
|
|
21
|
+
const transaction = (fn) => {
|
|
22
|
+
db.exec("BEGIN");
|
|
23
|
+
try {
|
|
24
|
+
fn();
|
|
25
|
+
db.exec("COMMIT");
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
db.exec("ROLLBACK");
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
rebuild(list) {
|
|
34
|
+
transaction(() => { fts.clear(); fts.upsert(list); });
|
|
35
|
+
entries.clear();
|
|
36
|
+
for (const e of list)
|
|
37
|
+
entries.set(e.uri, e);
|
|
38
|
+
lexical = buildLexical([...entries.values()]);
|
|
39
|
+
},
|
|
40
|
+
apply(delta) {
|
|
41
|
+
const upserts = [...delta.added, ...delta.changed];
|
|
42
|
+
transaction(() => { fts.remove(delta.removed); fts.upsert(upserts); });
|
|
43
|
+
for (const uri of delta.removed)
|
|
44
|
+
entries.delete(uri);
|
|
45
|
+
for (const e of upserts)
|
|
46
|
+
entries.set(e.uri, e);
|
|
47
|
+
lexical = buildLexical([...entries.values()]);
|
|
48
|
+
},
|
|
49
|
+
search(query, opts) {
|
|
50
|
+
const started = performance.now();
|
|
51
|
+
const base = { traceId: randomUUID(), query, mode: opts.mode };
|
|
52
|
+
const done = (candidates, reason) => ({
|
|
53
|
+
...base, candidates, ...(reason ? { abstained: { reason, hint: ABSTAIN_HINTS[reason] } } : {}),
|
|
54
|
+
elapsedMs: performance.now() - started,
|
|
55
|
+
});
|
|
56
|
+
const text = normalize(query ?? "");
|
|
57
|
+
if (!text)
|
|
58
|
+
return done([], "empty_query");
|
|
59
|
+
const fused = fuse(lexical.search(text), fts.search(text), terms(text)).filter(f => entries.has(f.uri));
|
|
60
|
+
if (!fused.length)
|
|
61
|
+
return done([], "no_overlap");
|
|
62
|
+
const allowed = opts.mode === "automatic" ? fused.filter(f => entries.get(f.uri).invocation === "automatic") : fused;
|
|
63
|
+
if (!allowed.length)
|
|
64
|
+
return done([], "explicit_only");
|
|
65
|
+
if (allowed[0].score < threshold())
|
|
66
|
+
return done([], "below_threshold");
|
|
67
|
+
const limit = Math.max(1, Math.floor(opts.limit));
|
|
68
|
+
return done(allowed.slice(0, limit).map((f, i) => {
|
|
69
|
+
const e = entries.get(f.uri);
|
|
70
|
+
return { uri: e.uri, name: e.name, description: e.description, score: f.score, rank: i + 1, invocation: e.invocation, digest: e.digest, matched: f.matched };
|
|
71
|
+
}));
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|