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,69 @@
|
|
|
1
|
+
export const normalize = (value) => value.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim();
|
|
2
|
+
const STOPWORDS = new Set(("a an the and or to of for in on with from by is are be it this that do please use using when how me my can could would should not only then after before need want help " +
|
|
3
|
+
"해줘 해주세요 알려줘 작성해줘 만들어줘 확인해줘 검토해줘 필요 없어 있습니다 합니다 작업 사용 통해 대한 위한 관련 현재 다음 먼저 실제 하나 것 수 및 또는 하고 그리고 방법 " +
|
|
4
|
+
// Bare particles left over when a Latin/digit token carries a Korean particle ("api가", "19로").
|
|
5
|
+
"은 는 이 가 을 를 의 도 로 와 과 에").split(" "));
|
|
6
|
+
export function stem(word) {
|
|
7
|
+
if (/^[가-힣]+$/.test(word) && word.length > 2) {
|
|
8
|
+
const stripped = word.replace(/(?:해주세요|해줘|하기|하게|하는|하면서|하려는|으로|에서|에게|에는|까지|부터|처럼|보다|은|는|을|를|이|가|과|와|의|도|로)$/, "");
|
|
9
|
+
return stripped.length >= 2 ? stripped : word;
|
|
10
|
+
}
|
|
11
|
+
return /^[a-z]{5,}s$/.test(word) && !word.endsWith("ss") ? word.slice(0, -1) : word;
|
|
12
|
+
}
|
|
13
|
+
/** Raw word tokens of normalized text, split where Hangul meets Latin/digits ("api가" -> "api", "가"). */
|
|
14
|
+
export function words(text) {
|
|
15
|
+
return (normalize(text).match(/[\p{L}\p{N}]+/gu) ?? [])
|
|
16
|
+
.flatMap(word => word.split(/(?<=[가-힣])(?=[a-z0-9])|(?<=[a-z0-9])(?=[가-힣])/));
|
|
17
|
+
}
|
|
18
|
+
export const isStopword = (word) => STOPWORDS.has(word);
|
|
19
|
+
export function terms(text) {
|
|
20
|
+
return [...new Set(words(text).filter(word => !STOPWORDS.has(word)).map(stem).filter(word => !STOPWORDS.has(word)))];
|
|
21
|
+
}
|
|
22
|
+
export function keywordsOf(entry) {
|
|
23
|
+
return [...entry.examples, ...entry.aliasesKo, ...(entry.whenToUse ? terms(entry.whenToUse) : [])].map(normalize).filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
export function buildLexical(entries) {
|
|
26
|
+
const frequency = new Map();
|
|
27
|
+
const rows = entries.map(entry => {
|
|
28
|
+
const name = normalize(entry.name), keywords = keywordsOf(entry);
|
|
29
|
+
const weights = new Map();
|
|
30
|
+
for (const [field, weight] of [[entry.description, 1], [keywords.join(" "), 4], [name, 8]]) {
|
|
31
|
+
for (const term of terms(field))
|
|
32
|
+
weights.set(term, weight);
|
|
33
|
+
}
|
|
34
|
+
for (const term of weights.keys())
|
|
35
|
+
frequency.set(term, (frequency.get(term) ?? 0) + 1);
|
|
36
|
+
return { uri: entry.uri, name, keywords, nameTerms: new Set(terms(name)), weights };
|
|
37
|
+
});
|
|
38
|
+
const size = rows.length;
|
|
39
|
+
return {
|
|
40
|
+
search(query) {
|
|
41
|
+
const text = normalize(query);
|
|
42
|
+
if (!text)
|
|
43
|
+
return [];
|
|
44
|
+
const queryTerms = terms(text);
|
|
45
|
+
const hits = [];
|
|
46
|
+
for (const { uri, name, keywords, nameTerms, weights } of rows) {
|
|
47
|
+
const anchor = name === text ? 2 : keywords.includes(text) ? 1 : 0;
|
|
48
|
+
let score = anchor === 2 ? 1000 : anchor === 1 ? 300 : 0;
|
|
49
|
+
let anchored = anchor > 0;
|
|
50
|
+
const matched = [];
|
|
51
|
+
for (const word of queryTerms) {
|
|
52
|
+
const weight = weights.get(word);
|
|
53
|
+
if (!weight)
|
|
54
|
+
continue;
|
|
55
|
+
matched.push(word);
|
|
56
|
+
anchored ||= nameTerms.has(word) || keywords.includes(word);
|
|
57
|
+
const rarity = Math.log(1 + (size + .5) / ((frequency.get(word) ?? 0) + .5));
|
|
58
|
+
score += weight * rarity;
|
|
59
|
+
}
|
|
60
|
+
if (!score || (queryTerms.length > 1 && matched.length === 1 && !anchored))
|
|
61
|
+
continue;
|
|
62
|
+
// Prefer coverage without letting unmatched narrative details erase a result.
|
|
63
|
+
score *= 1 + matched.length / Math.max(queryTerms.length, 1);
|
|
64
|
+
hits.push({ uri, score, matched, anchor });
|
|
65
|
+
}
|
|
66
|
+
return hits.sort((a, b) => b.score - a.score || (a.uri < b.uri ? -1 : a.uri > b.uri ? 1 : 0));
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import express from "express";
|
|
5
|
+
import { registerRoutes } from "./api/routes.js";
|
|
6
|
+
import { authGate, deny, localOnly, requireJson } from "./api/security.js";
|
|
7
|
+
import { createSseHub } from "./api/sse.js";
|
|
8
|
+
import { registerStatic } from "./api/static.js";
|
|
9
|
+
import { createCatalog } from "./catalog/index.js";
|
|
10
|
+
import { ensureGisoolHome, ensureToken, resolveGisoolHome, resolveHome } from "./config.js";
|
|
11
|
+
import { createIngestDeps } from "./ingest/pipeline.js";
|
|
12
|
+
import { registerMcp } from "./mcp/http.js";
|
|
13
|
+
import { packageVersion } from "./mcp/tools.js";
|
|
14
|
+
import { createSearchIndex } from "./search/index.js";
|
|
15
|
+
import { openDb, selfCheck } from "./store/db.js";
|
|
16
|
+
import { createStore } from "./store/index.js";
|
|
17
|
+
import { assertNodeVersion, assertPlatform, GisoolStartupError } from "./version.js";
|
|
18
|
+
export const HOST = "127.0.0.1";
|
|
19
|
+
export const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
20
|
+
const DAY_MS = 86_400_000;
|
|
21
|
+
export function createFileLogger(path, maxBytes = LOG_MAX_BYTES) {
|
|
22
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
23
|
+
return (msg) => {
|
|
24
|
+
try {
|
|
25
|
+
if ((statSync(path, { throwIfNoEntry: false })?.size ?? 0) >= maxBytes)
|
|
26
|
+
renameSync(path, `${path}.1`);
|
|
27
|
+
appendFileSync(path, `${new Date().toISOString()} ${msg}\n`);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
process.stderr.write(`gisool: cannot write ${path}: ${error.message}\n${msg}\n`);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function listen(server, port) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const onError = (error) => {
|
|
37
|
+
server.off("listening", onListening);
|
|
38
|
+
reject(error.code === "EADDRINUSE" ? new GisoolStartupError("port_in_use", `${HOST}:${port} is already in use`) : error);
|
|
39
|
+
};
|
|
40
|
+
const onListening = () => {
|
|
41
|
+
server.off("error", onError);
|
|
42
|
+
resolve(server.address().port);
|
|
43
|
+
};
|
|
44
|
+
server.once("error", onError);
|
|
45
|
+
server.once("listening", onListening);
|
|
46
|
+
server.listen(port, HOST);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export async function startServer(config, deps = {}) {
|
|
50
|
+
assertNodeVersion();
|
|
51
|
+
assertPlatform();
|
|
52
|
+
const gisoolHome = deps.gisoolHome ?? resolveGisoolHome(process.env);
|
|
53
|
+
if (deps.gisoolHome === undefined)
|
|
54
|
+
ensureGisoolHome(process.env);
|
|
55
|
+
else
|
|
56
|
+
mkdirSync(gisoolHome, { recursive: true, mode: 0o700 });
|
|
57
|
+
const log = deps.log ?? createFileLogger(join(gisoolHome, "logs", "daemon.log"));
|
|
58
|
+
const token = ensureToken(gisoolHome);
|
|
59
|
+
const version = packageVersion();
|
|
60
|
+
const db = openDb(join(gisoolHome, "gisool.db"));
|
|
61
|
+
const cleanup = [];
|
|
62
|
+
let store;
|
|
63
|
+
try {
|
|
64
|
+
selfCheck(db);
|
|
65
|
+
store = deps.openStore ? deps.openStore() : createStore(db, { config, gisoolHome, home: resolveHome(process.env) });
|
|
66
|
+
const ownStore = store;
|
|
67
|
+
cleanup.push(() => ownStore.close());
|
|
68
|
+
if (deps.openStore)
|
|
69
|
+
cleanup.push(() => db.close());
|
|
70
|
+
const catalog = createCatalog({ roots: config.roots, watch: true, log });
|
|
71
|
+
cleanup.push(() => catalog.close());
|
|
72
|
+
await catalog.refresh();
|
|
73
|
+
const search = createSearchIndex(db);
|
|
74
|
+
search.rebuild(catalog.list());
|
|
75
|
+
ownStore.upsertSkills(catalog.list());
|
|
76
|
+
catalog.onDelta((delta) => {
|
|
77
|
+
try {
|
|
78
|
+
search.apply(delta);
|
|
79
|
+
ownStore.upsertSkills([...delta.added, ...delta.changed]);
|
|
80
|
+
if (delta.removed.length)
|
|
81
|
+
ownStore.markRemoved(delta.removed);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
log(`applying catalog delta failed: ${error.message}`);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
const sessionRowStmt = db.prepare("SELECT runtime, started_at FROM sessions WHERE id = ?");
|
|
88
|
+
const lookupRow = (id) => {
|
|
89
|
+
const meta = sessionRowStmt.get(id);
|
|
90
|
+
if (!meta)
|
|
91
|
+
return undefined;
|
|
92
|
+
return ownStore.sessions({ from: meta.started_at, to: meta.started_at + 1, runtime: [meta.runtime] }).find((r) => r.id === id);
|
|
93
|
+
};
|
|
94
|
+
const sse = createSseHub(lookupRow, log);
|
|
95
|
+
cleanup.push(() => sse.close());
|
|
96
|
+
const ingestDeps = createIngestDeps(ownStore, {
|
|
97
|
+
captureLevel: config.captureLevel,
|
|
98
|
+
publish: (change) => sse.notify(change.sessionId),
|
|
99
|
+
log: (message, error) => log(`ingest: ${message}: ${error instanceof Error ? error.message : String(error)}`),
|
|
100
|
+
});
|
|
101
|
+
let boundPort = config.port;
|
|
102
|
+
const app = express();
|
|
103
|
+
app.disable("x-powered-by");
|
|
104
|
+
// Routes match exactly as authGate classifies them; `/API/...` must not reach an `/api/...` handler.
|
|
105
|
+
app.set("case sensitive routing", true);
|
|
106
|
+
app.use(localOnly(() => boundPort));
|
|
107
|
+
// Authenticate before parsing so unauthenticated callers cannot make the daemon buffer bodies or bump reject counters.
|
|
108
|
+
app.use(authGate(token));
|
|
109
|
+
const smallJson = express.json({ limit: "1mb" });
|
|
110
|
+
const otlpJson = express.json({ limit: "8mb" });
|
|
111
|
+
app.use((req, res, next) => {
|
|
112
|
+
const parser = req.path.startsWith("/ingest/otlp/") ? otlpJson : smallJson;
|
|
113
|
+
parser(req, res, (error) => {
|
|
114
|
+
if (!error)
|
|
115
|
+
return next();
|
|
116
|
+
const type = error.type;
|
|
117
|
+
if (type === "entity.too.large") {
|
|
118
|
+
ownStore.countIngest("rejected", "payload_too_large");
|
|
119
|
+
return deny(res, 413, "payload_too_large");
|
|
120
|
+
}
|
|
121
|
+
if (req.path.startsWith("/ingest/"))
|
|
122
|
+
ownStore.countIngest("rejected", "bad_json");
|
|
123
|
+
deny(res, 400, "bad_json");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
app.use(requireJson((req) => {
|
|
127
|
+
if (req.path.startsWith("/ingest/"))
|
|
128
|
+
ownStore.countIngest("rejected", "unsupported_media_type");
|
|
129
|
+
}));
|
|
130
|
+
registerRoutes(app, { store: ownStore, catalog, ingest: ingestDeps, sse, version, log });
|
|
131
|
+
const mcp = registerMcp(app, { catalog, search, store: ownStore, ingest: ingestDeps, token });
|
|
132
|
+
cleanup.push(() => void mcp.close());
|
|
133
|
+
registerStatic(app, { token, ...(deps.dashboardDir ? { dir: deps.dashboardDir } : {}) });
|
|
134
|
+
app.use((_req, res) => deny(res, 404, "not_found"));
|
|
135
|
+
app.use((error, req, res, _next) => {
|
|
136
|
+
const status = error.status;
|
|
137
|
+
if (status === 400)
|
|
138
|
+
return deny(res, 400, "bad_request");
|
|
139
|
+
// serve-static raises 403 for malformed paths (e.g. encoded traversal); nothing is served, so report not_found.
|
|
140
|
+
if (status === 403 || status === 404)
|
|
141
|
+
return deny(res, 404, "not_found");
|
|
142
|
+
if (status !== undefined && status > 400 && status < 500)
|
|
143
|
+
return deny(res, status, "request_rejected");
|
|
144
|
+
log(`${req.method} ${req.path} failed: ${error.stack ?? String(error)}`);
|
|
145
|
+
if (!res.headersSent)
|
|
146
|
+
deny(res, 500, "internal_error");
|
|
147
|
+
});
|
|
148
|
+
const server = createServer(app);
|
|
149
|
+
boundPort = await listen(server, config.port);
|
|
150
|
+
let retentionTimer;
|
|
151
|
+
if (config.retentionDays !== null) {
|
|
152
|
+
const days = config.retentionDays;
|
|
153
|
+
const runRetention = () => {
|
|
154
|
+
try {
|
|
155
|
+
log(`retention removed ${ownStore.retention(days)} sessions older than ${days} days`);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
log(`retention failed: ${error.message}`);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
runRetention();
|
|
162
|
+
retentionTimer = setInterval(runRetention, DAY_MS);
|
|
163
|
+
retentionTimer.unref();
|
|
164
|
+
}
|
|
165
|
+
let closing;
|
|
166
|
+
const onSignal = (signal) => {
|
|
167
|
+
log(`received ${signal}, shutting down`);
|
|
168
|
+
void close().then(() => process.exit(0));
|
|
169
|
+
};
|
|
170
|
+
const handleSignals = deps.handleSignals ?? true;
|
|
171
|
+
if (handleSignals) {
|
|
172
|
+
process.once("SIGINT", onSignal);
|
|
173
|
+
process.once("SIGTERM", onSignal);
|
|
174
|
+
}
|
|
175
|
+
const close = () => {
|
|
176
|
+
closing ??= (async () => {
|
|
177
|
+
if (handleSignals) {
|
|
178
|
+
process.off("SIGINT", onSignal);
|
|
179
|
+
process.off("SIGTERM", onSignal);
|
|
180
|
+
}
|
|
181
|
+
if (retentionTimer)
|
|
182
|
+
clearInterval(retentionTimer);
|
|
183
|
+
await mcp.close();
|
|
184
|
+
sse.close();
|
|
185
|
+
server.closeAllConnections();
|
|
186
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
187
|
+
for (const fn of cleanup.reverse()) {
|
|
188
|
+
try {
|
|
189
|
+
fn();
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
log(`shutdown step failed: ${error.message}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
log("gisool stopped");
|
|
196
|
+
})();
|
|
197
|
+
return closing;
|
|
198
|
+
};
|
|
199
|
+
log(`gisool ${version} listening on http://${HOST}:${boundPort}`);
|
|
200
|
+
return { port: boundPort, host: HOST, close };
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
for (const fn of cleanup.reverse()) {
|
|
204
|
+
try {
|
|
205
|
+
fn();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// Startup already failed; the original error is the one worth reporting.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (!store)
|
|
212
|
+
db.close();
|
|
213
|
+
log(`startup failed: ${error.message}`);
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
}
|
package/dist/store/db.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { GisoolStartupError } from "../version.js";
|
|
5
|
+
export const SCHEMA_VERSION = 1;
|
|
6
|
+
/** migrations[i] upgrades user_version i -> i + 1. */
|
|
7
|
+
function migrations() {
|
|
8
|
+
return [readFileSync(new URL("./schema.sql", import.meta.url), "utf8")];
|
|
9
|
+
}
|
|
10
|
+
function userVersion(db) {
|
|
11
|
+
const row = db.prepare("PRAGMA user_version").get();
|
|
12
|
+
return row?.user_version ?? 0;
|
|
13
|
+
}
|
|
14
|
+
export function openDb(path) {
|
|
15
|
+
if (path !== ":memory:")
|
|
16
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
17
|
+
const db = new DatabaseSync(path);
|
|
18
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
19
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
20
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
21
|
+
migrate(db);
|
|
22
|
+
return db;
|
|
23
|
+
}
|
|
24
|
+
export function migrate(db) {
|
|
25
|
+
const steps = migrations();
|
|
26
|
+
let version = userVersion(db);
|
|
27
|
+
while (version < steps.length) {
|
|
28
|
+
const sql = steps[version];
|
|
29
|
+
db.exec("BEGIN IMMEDIATE");
|
|
30
|
+
try {
|
|
31
|
+
db.exec(sql);
|
|
32
|
+
db.exec(`PRAGMA user_version = ${version + 1}`);
|
|
33
|
+
db.exec("COMMIT");
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
db.exec("ROLLBACK");
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
version = userVersion(db);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Proves FTS5 with the trigram tokenizer works and matches Korean substrings.
|
|
44
|
+
* Uses a TEMP table on the given connection (or a fresh in-memory DB) and drops it afterwards.
|
|
45
|
+
*/
|
|
46
|
+
export function selfCheck(db) {
|
|
47
|
+
const conn = db ?? new DatabaseSync(":memory:");
|
|
48
|
+
let ok = false;
|
|
49
|
+
let detail = "";
|
|
50
|
+
try {
|
|
51
|
+
conn.exec("DROP TABLE IF EXISTS temp.gisool_selfcheck");
|
|
52
|
+
conn.exec("CREATE VIRTUAL TABLE temp.gisool_selfcheck USING fts5(body, tokenize = 'trigram')");
|
|
53
|
+
conn.prepare("INSERT INTO temp.gisool_selfcheck(body) VALUES (?)").run("커밋 메시지 작성해줘");
|
|
54
|
+
const row = conn.prepare("SELECT count(*) AS n FROM temp.gisool_selfcheck WHERE gisool_selfcheck MATCH ?").get('"메시지"');
|
|
55
|
+
ok = (row?.n ?? 0) === 1;
|
|
56
|
+
if (!ok)
|
|
57
|
+
detail = "trigram match for '메시지' returned no rows";
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
detail = error instanceof Error ? error.message : String(error);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
try {
|
|
64
|
+
conn.exec("DROP TABLE IF EXISTS temp.gisool_selfcheck");
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Cleanup failure is irrelevant once the outcome is known; the original failure is reported below.
|
|
68
|
+
}
|
|
69
|
+
if (!db)
|
|
70
|
+
conn.close();
|
|
71
|
+
}
|
|
72
|
+
if (!ok)
|
|
73
|
+
throw new GisoolStartupError("fts5_trigram_unavailable", detail);
|
|
74
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { DEFAULT_ROOTS, expandHome } from "../config.js";
|
|
5
|
+
import { parseJson, readAliasesOverrides, storeError, writeAliasesOverrides } from "./writes.js";
|
|
6
|
+
export const EXPORT_FILE = "gisool-export.json";
|
|
7
|
+
export const HISTORY_FILE = "history.db";
|
|
8
|
+
const HISTORY_TABLES = ["sessions", "events", "loads"];
|
|
9
|
+
function tildify(path, home) {
|
|
10
|
+
const h = home.replace(/\/+$/, "");
|
|
11
|
+
if (path === h)
|
|
12
|
+
return "~";
|
|
13
|
+
if (path.startsWith(h + "/"))
|
|
14
|
+
return "~/" + path.slice(h.length + 1);
|
|
15
|
+
return path;
|
|
16
|
+
}
|
|
17
|
+
function readConfigFile(gisoolHome) {
|
|
18
|
+
if (!gisoolHome)
|
|
19
|
+
return undefined;
|
|
20
|
+
const p = join(gisoolHome, "config.json");
|
|
21
|
+
return existsSync(p) ? parseJson(readFileSync(p, "utf8"), {}) : undefined;
|
|
22
|
+
}
|
|
23
|
+
export function createPortable(ctx) {
|
|
24
|
+
const { db } = ctx;
|
|
25
|
+
const home = () => ctx.opts.home ?? homedir();
|
|
26
|
+
function existingEvalKeys() {
|
|
27
|
+
const rows = db.prepare("SELECT query, expected_uri FROM eval_cases").all();
|
|
28
|
+
return new Set(rows.map((r) => JSON.stringify([r.query, r.expected_uri])));
|
|
29
|
+
}
|
|
30
|
+
function historyPlan() {
|
|
31
|
+
const counts = { sessions: 0, events: 0, loads: 0 };
|
|
32
|
+
let skipped = 0;
|
|
33
|
+
for (const t of HISTORY_TABLES) {
|
|
34
|
+
const key = t === "loads" ? "load_id" : "id";
|
|
35
|
+
const row = db.prepare(`SELECT SUM(CASE WHEN ${key} IN (SELECT ${key} FROM main.${t}) THEN 0 ELSE 1 END) AS fresh, COUNT(*) AS total FROM hist.${t}`).get();
|
|
36
|
+
counts[t] = row.fresh ?? 0;
|
|
37
|
+
skipped += row.total - (row.fresh ?? 0);
|
|
38
|
+
}
|
|
39
|
+
return { counts, skipped };
|
|
40
|
+
}
|
|
41
|
+
function copyHistory() {
|
|
42
|
+
for (const t of HISTORY_TABLES) {
|
|
43
|
+
const key = t === "loads" ? "load_id" : "id";
|
|
44
|
+
const cols = db.prepare(`PRAGMA main.table_info(${t})`).all().map((c) => c.name).join(", ");
|
|
45
|
+
db.prepare(`INSERT INTO main.${t} (${cols}) SELECT ${cols} FROM hist.${t} WHERE ${key} NOT IN (SELECT ${key} FROM main.${t})`).run();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
exportTo(dir, opts) {
|
|
50
|
+
mkdirSync(dir, { recursive: true });
|
|
51
|
+
const raw = readConfigFile(ctx.opts.gisoolHome);
|
|
52
|
+
const source = raw ?? (ctx.opts.config ? { ...ctx.opts.config } : {});
|
|
53
|
+
const { token: _token, roots: rawRoots, ...config } = source;
|
|
54
|
+
void _token;
|
|
55
|
+
const roots = (Array.isArray(rawRoots) ? rawRoots : [])
|
|
56
|
+
.map((r) => ({ id: r.id, path: tildify(r.path, home()) }));
|
|
57
|
+
const evalRows = db.prepare("SELECT query, expected_uri, from_trace_id FROM eval_cases ORDER BY created_at, rowid").all();
|
|
58
|
+
const doc = {
|
|
59
|
+
version: 1,
|
|
60
|
+
config,
|
|
61
|
+
roots,
|
|
62
|
+
evalCases: evalRows.map((r) => ({ query: r.query, ...(r.expected_uri !== null ? { expectedUri: r.expected_uri } : {}),
|
|
63
|
+
...(r.from_trace_id !== null ? { fromTraceId: r.from_trace_id } : {}) })),
|
|
64
|
+
aliasesOverrides: readAliasesOverrides(db),
|
|
65
|
+
};
|
|
66
|
+
const out = join(dir, EXPORT_FILE);
|
|
67
|
+
writeFileSync(out, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
|
|
68
|
+
chmodSync(out, 0o600);
|
|
69
|
+
const history = join(dir, HISTORY_FILE);
|
|
70
|
+
rmSync(history, { force: true });
|
|
71
|
+
if (opts.withHistory) {
|
|
72
|
+
db.prepare("VACUUM INTO ?").run(resolve(history));
|
|
73
|
+
chmodSync(history, 0o600);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
importFrom(dir, opts) {
|
|
77
|
+
const file = join(dir, EXPORT_FILE);
|
|
78
|
+
if (!existsSync(file))
|
|
79
|
+
throw storeError("no_export_found", `no ${EXPORT_FILE} in ${dir}`);
|
|
80
|
+
const doc = parseJson(readFileSync(file, "utf8"), {});
|
|
81
|
+
if (doc.version !== 1)
|
|
82
|
+
throw storeError("unsupported_export_version", `unsupported export version: ${String(doc.version)}`);
|
|
83
|
+
const apply = opts.merge;
|
|
84
|
+
const evalKeys = existingEvalKeys();
|
|
85
|
+
const evalToAdd = [];
|
|
86
|
+
let evalSkipped = 0;
|
|
87
|
+
for (const c of doc.evalCases ?? []) {
|
|
88
|
+
const key = JSON.stringify([c.query, c.expectedUri ?? null]);
|
|
89
|
+
if (evalKeys.has(key))
|
|
90
|
+
evalSkipped++;
|
|
91
|
+
else {
|
|
92
|
+
evalKeys.add(key);
|
|
93
|
+
evalToAdd.push(c);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const currentAliases = readAliasesOverrides(db);
|
|
97
|
+
const aliasAdded = Object.keys(doc.aliasesOverrides ?? {}).filter((k) => !(k in currentAliases));
|
|
98
|
+
const aliasSkipped = Object.keys(doc.aliasesOverrides ?? {}).filter((k) => k in currentAliases);
|
|
99
|
+
const gisoolHome = ctx.opts.gisoolHome;
|
|
100
|
+
const existingRaw = readConfigFile(gisoolHome);
|
|
101
|
+
const configBase = existingRaw ?? {};
|
|
102
|
+
const existingRoots = (Array.isArray(configBase.roots) ? configBase.roots : existingRaw ? [] : DEFAULT_ROOTS);
|
|
103
|
+
const rootsAdded = [];
|
|
104
|
+
const rootsSkipped = [];
|
|
105
|
+
const configAdded = [];
|
|
106
|
+
const configKept = [];
|
|
107
|
+
if (gisoolHome) {
|
|
108
|
+
const knownPaths = new Set(existingRoots.map((r) => expandHome(r.path, home())));
|
|
109
|
+
const knownIds = new Set(existingRoots.map((r) => r.id));
|
|
110
|
+
for (const r of doc.roots ?? []) {
|
|
111
|
+
const expanded = expandHome(r.path, home());
|
|
112
|
+
if (knownIds.has(r.id) || knownPaths.has(expanded))
|
|
113
|
+
rootsSkipped.push(r.id);
|
|
114
|
+
else {
|
|
115
|
+
rootsAdded.push({ id: r.id, path: tildify(expanded, home()) });
|
|
116
|
+
knownIds.add(r.id);
|
|
117
|
+
knownPaths.add(expanded);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const k of Object.keys(doc.config ?? {})) {
|
|
121
|
+
if (k === "token" || k === "roots")
|
|
122
|
+
continue;
|
|
123
|
+
if (k in configBase)
|
|
124
|
+
configKept.push(k);
|
|
125
|
+
else
|
|
126
|
+
configAdded.push(k);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const historyPath = join(dir, HISTORY_FILE);
|
|
130
|
+
let history = null;
|
|
131
|
+
const hasHistory = existsSync(historyPath);
|
|
132
|
+
if (hasHistory) {
|
|
133
|
+
db.prepare("ATTACH DATABASE ? AS hist").run(resolve(historyPath));
|
|
134
|
+
try {
|
|
135
|
+
const version = db.prepare("PRAGMA hist.user_version").get().user_version;
|
|
136
|
+
if (version !== 1)
|
|
137
|
+
throw storeError("unsupported_history_version", `history.db user_version ${version}`);
|
|
138
|
+
const plan = historyPlan();
|
|
139
|
+
history = { ...plan.counts, skipped: plan.skipped };
|
|
140
|
+
if (apply) {
|
|
141
|
+
db.exec("BEGIN IMMEDIATE");
|
|
142
|
+
try {
|
|
143
|
+
copyHistory();
|
|
144
|
+
db.exec("COMMIT");
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
db.exec("ROLLBACK");
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
db.exec("DETACH DATABASE hist");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (apply) {
|
|
157
|
+
const insert = db.prepare("INSERT INTO eval_cases (id, query, expected_uri, from_trace_id, created_at) VALUES (lower(hex(randomblob(16))), ?, ?, ?, ?)");
|
|
158
|
+
db.exec("BEGIN IMMEDIATE");
|
|
159
|
+
try {
|
|
160
|
+
for (const c of evalToAdd)
|
|
161
|
+
insert.run(c.query, c.expectedUri ?? null, c.fromTraceId ?? null, ctx.now());
|
|
162
|
+
if (aliasAdded.length > 0) {
|
|
163
|
+
const next = { ...currentAliases };
|
|
164
|
+
for (const k of aliasAdded)
|
|
165
|
+
next[k] = doc.aliasesOverrides[k];
|
|
166
|
+
writeAliasesOverrides(db, next);
|
|
167
|
+
}
|
|
168
|
+
db.exec("COMMIT");
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
db.exec("ROLLBACK");
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
if (gisoolHome && (rootsAdded.length > 0 || configAdded.length > 0)) {
|
|
175
|
+
const next = { ...configBase };
|
|
176
|
+
for (const k of configAdded)
|
|
177
|
+
next[k] = doc.config[k];
|
|
178
|
+
next.roots = [...existingRoots, ...rootsAdded];
|
|
179
|
+
mkdirSync(gisoolHome, { recursive: true, mode: 0o700 });
|
|
180
|
+
const p = join(gisoolHome, "config.json");
|
|
181
|
+
writeFileSync(p, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
182
|
+
chmodSync(p, 0o600);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const historyAdded = history ? history.sessions + history.events + history.loads : 0;
|
|
186
|
+
return {
|
|
187
|
+
added: evalToAdd.length + rootsAdded.length + configAdded.length + aliasAdded.length + historyAdded,
|
|
188
|
+
skipped: evalSkipped + rootsSkipped.length + configKept.length + aliasSkipped.length + (history?.skipped ?? 0),
|
|
189
|
+
details: {
|
|
190
|
+
evalCases: { added: evalToAdd.length, skipped: evalSkipped },
|
|
191
|
+
roots: { added: rootsAdded.map((r) => r.id), skipped: rootsSkipped },
|
|
192
|
+
configKeys: { added: configAdded, kept: configKept },
|
|
193
|
+
aliases: { added: aliasAdded, skipped: aliasSkipped },
|
|
194
|
+
history,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createPortable } from "./export.js";
|
|
2
|
+
import { createQueries } from "./queries.js";
|
|
3
|
+
import { createWrites } from "./writes.js";
|
|
4
|
+
export { EXPORT_FILE, HISTORY_FILE } from "./export.js";
|
|
5
|
+
export { adoptionState, classifyTaskType, localDowHour, taskTypeRules } from "./queries.js";
|
|
6
|
+
/**
|
|
7
|
+
* importFrom(dir, { merge: false }) is a dry run: it returns the same counts (plus `details`) without writing anything;
|
|
8
|
+
* { merge: true } applies, never overwriting existing config keys, roots, aliases, eval cases, sessions, or events.
|
|
9
|
+
*/
|
|
10
|
+
export function createStore(db, opts = {}) {
|
|
11
|
+
const ctx = { db, opts, now: opts.now ?? Date.now };
|
|
12
|
+
return { ...createWrites(ctx), ...createQueries(ctx), ...createPortable(ctx), close: () => db.close() };
|
|
13
|
+
}
|