vibeusage 0.3.4 → 0.4.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/README.md +22 -10
- package/README.zh-CN.md +1 -1
- package/package.json +9 -1
- package/src/commands/init.js +2 -2
- package/src/commands/status.js +16 -0
- package/src/commands/sync.js +113 -18
- package/src/commands/uninstall.js +10 -0
- package/src/lib/diagnostics.js +28 -0
- package/src/lib/hermes-config.js +172 -0
- package/src/lib/hermes-usage-ledger.js +123 -0
- package/src/lib/integrations/context.js +18 -0
- package/src/lib/integrations/hermes.js +96 -0
- package/src/lib/integrations/index.js +4 -0
- package/src/lib/integrations/kimi.js +105 -0
- package/src/lib/kimi-config.js +221 -0
- package/src/lib/opencode-usage-audit.js +2 -3
- package/src/lib/rollout.js +167 -50
- package/src/templates/hermes-vibeusage-plugin/__init__.py +75 -0
- package/src/templates/hermes-vibeusage-plugin/plugin.yaml +9 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const fs = require("node:fs/promises");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
|
|
4
|
+
const ALLOWED_HERMES_LEDGER_FIELDS = [
|
|
5
|
+
"version",
|
|
6
|
+
"type",
|
|
7
|
+
"source",
|
|
8
|
+
"session_id",
|
|
9
|
+
"platform",
|
|
10
|
+
"model",
|
|
11
|
+
"provider",
|
|
12
|
+
"api_mode",
|
|
13
|
+
"api_call_count",
|
|
14
|
+
"input_tokens",
|
|
15
|
+
"output_tokens",
|
|
16
|
+
"cache_read_tokens",
|
|
17
|
+
"cache_write_tokens",
|
|
18
|
+
"reasoning_tokens",
|
|
19
|
+
"total_tokens",
|
|
20
|
+
"finish_reason",
|
|
21
|
+
"emitted_at",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function resolveHermesUsageLedgerPaths({ trackerDir } = {}) {
|
|
25
|
+
if (!trackerDir) throw new Error("trackerDir is required");
|
|
26
|
+
return {
|
|
27
|
+
ledgerPath: path.join(trackerDir, "hermes.usage.jsonl"),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readHermesUsageLedger({ trackerDir, offset = 0 } = {}) {
|
|
32
|
+
const { ledgerPath } = resolveHermesUsageLedgerPaths({ trackerDir });
|
|
33
|
+
const buffer = await fs.readFile(ledgerPath).catch((err) => {
|
|
34
|
+
if (err && err.code === "ENOENT") return null;
|
|
35
|
+
throw err;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (!buffer) {
|
|
39
|
+
return { events: [], endOffset: 0 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const startOffset = Math.max(0, Number(offset || 0));
|
|
43
|
+
const fileEndOffset = buffer.length;
|
|
44
|
+
if (startOffset >= fileEndOffset) {
|
|
45
|
+
return { events: [], endOffset: fileEndOffset };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const newlineIndex = buffer.lastIndexOf(0x0a);
|
|
49
|
+
if (newlineIndex < startOffset) {
|
|
50
|
+
return { events: [], endOffset: startOffset };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const endOffset = newlineIndex + 1;
|
|
54
|
+
const raw = buffer.subarray(startOffset, endOffset).toString("utf8");
|
|
55
|
+
const events = raw
|
|
56
|
+
.split(/\r?\n/)
|
|
57
|
+
.map((line) => line.trim())
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.map((line) => {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(line);
|
|
62
|
+
} catch (_err) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.map(stripToAllowedHermesFields)
|
|
68
|
+
.filter(isHermesLedgerEvent);
|
|
69
|
+
|
|
70
|
+
return { events, endOffset };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readLastHermesUsageEvent({ trackerDir } = {}) {
|
|
74
|
+
const { ledgerPath } = resolveHermesUsageLedgerPaths({ trackerDir });
|
|
75
|
+
const text = await fs.readFile(ledgerPath, "utf8").catch((err) => {
|
|
76
|
+
if (err && err.code === "ENOENT") return null;
|
|
77
|
+
throw err;
|
|
78
|
+
});
|
|
79
|
+
if (!text) return null;
|
|
80
|
+
|
|
81
|
+
const lines = text.split(/\r?\n/);
|
|
82
|
+
for (let idx = lines.length - 1; idx >= 0; idx -= 1) {
|
|
83
|
+
const line = String(lines[idx] || "").trim();
|
|
84
|
+
if (!line) continue;
|
|
85
|
+
try {
|
|
86
|
+
const parsed = stripToAllowedHermesFields(JSON.parse(line));
|
|
87
|
+
if (isHermesLedgerEvent(parsed)) return parsed;
|
|
88
|
+
} catch (_err) {
|
|
89
|
+
// ignore malformed line
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function stripToAllowedHermesFields(event) {
|
|
96
|
+
const out = {};
|
|
97
|
+
for (const field of ALLOWED_HERMES_LEDGER_FIELDS) {
|
|
98
|
+
if (!Object.prototype.hasOwnProperty.call(event, field)) continue;
|
|
99
|
+
out[field] = event[field];
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isHermesLedgerEvent(event) {
|
|
105
|
+
if (!event || typeof event !== "object") return false;
|
|
106
|
+
if (normalizeString(event.source) !== "hermes") return false;
|
|
107
|
+
const type = normalizeString(event.type);
|
|
108
|
+
if (!type || !["session_start", "usage", "session_end"].includes(type)) return false;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeString(value) {
|
|
113
|
+
if (typeof value !== "string") return null;
|
|
114
|
+
const trimmed = value.trim();
|
|
115
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
ALLOWED_HERMES_LEDGER_FIELDS,
|
|
120
|
+
resolveHermesUsageLedgerPaths,
|
|
121
|
+
readHermesUsageLedger,
|
|
122
|
+
readLastHermesUsageEvent,
|
|
123
|
+
};
|
|
@@ -7,8 +7,14 @@ const {
|
|
|
7
7
|
resolveGeminiSettingsPath,
|
|
8
8
|
buildGeminiHookCommand,
|
|
9
9
|
} = require("../gemini-config");
|
|
10
|
+
const {
|
|
11
|
+
resolveKimiConfigDir,
|
|
12
|
+
resolveKimiConfigPath,
|
|
13
|
+
buildKimiHookCommand,
|
|
14
|
+
} = require("../kimi-config");
|
|
10
15
|
const { resolveOpencodeConfigDir } = require("../opencode-config");
|
|
11
16
|
const { resolveOpenclawSessionPluginPaths } = require("../openclaw-session-plugin");
|
|
17
|
+
const { resolveHermesPluginPaths } = require("../hermes-config");
|
|
12
18
|
const { resolveTrackerPaths } = require("../tracker-paths");
|
|
13
19
|
|
|
14
20
|
async function createIntegrationContext({
|
|
@@ -24,6 +30,7 @@ async function createIntegrationContext({
|
|
|
24
30
|
const codeHome = env.CODE_HOME || path.join(home, ".code");
|
|
25
31
|
const claudeDir = path.join(home, ".claude");
|
|
26
32
|
const geminiConfigDir = resolveGeminiConfigDir({ home, env });
|
|
33
|
+
const kimiConfigDir = resolveKimiConfigDir({ home, env });
|
|
27
34
|
const opencodeConfigDir = resolveOpencodeConfigDir({ home, env });
|
|
28
35
|
|
|
29
36
|
return {
|
|
@@ -54,9 +61,20 @@ async function createIntegrationContext({
|
|
|
54
61
|
settingsPath: resolveGeminiSettingsPath({ configDir: geminiConfigDir }),
|
|
55
62
|
hookCommand: buildGeminiHookCommand(resolvedNotifyPath),
|
|
56
63
|
},
|
|
64
|
+
kimi: {
|
|
65
|
+
configDir: kimiConfigDir,
|
|
66
|
+
configPath: resolveKimiConfigPath({ configDir: kimiConfigDir }),
|
|
67
|
+
hookCommand: buildKimiHookCommand(resolvedNotifyPath),
|
|
68
|
+
sessionsDir: path.join(kimiConfigDir, "sessions"),
|
|
69
|
+
},
|
|
57
70
|
opencode: {
|
|
58
71
|
configDir: opencodeConfigDir,
|
|
59
72
|
},
|
|
73
|
+
hermes: resolveHermesPluginPaths({
|
|
74
|
+
home,
|
|
75
|
+
env,
|
|
76
|
+
trackerDir: resolvedTrackerPaths.trackerDir,
|
|
77
|
+
}),
|
|
60
78
|
openclawSession: resolveOpenclawSessionPluginPaths({
|
|
61
79
|
home,
|
|
62
80
|
trackerDir: resolvedTrackerPaths.trackerDir,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const { isDir } = require("./utils");
|
|
2
|
+
const { probeHermesPlugin, installHermesPlugin, removeHermesPlugin } = require("../hermes-config");
|
|
3
|
+
|
|
4
|
+
module.exports = {
|
|
5
|
+
name: "hermes",
|
|
6
|
+
summaryLabel: "Hermes Plugin",
|
|
7
|
+
statusLabel: "Hermes plugin",
|
|
8
|
+
async probe(ctx) {
|
|
9
|
+
const hermesHomeExists = await isDir(ctx.hermes.hermesHome);
|
|
10
|
+
if (!hermesHomeExists) {
|
|
11
|
+
return baseProbe(this, {
|
|
12
|
+
status: "not_installed",
|
|
13
|
+
detail: "Hermes home not found",
|
|
14
|
+
initPreviewStatus: "skipped",
|
|
15
|
+
initPreviewDetail: "Hermes not detected",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const state = await probeHermesPlugin({
|
|
20
|
+
home: ctx.home,
|
|
21
|
+
env: ctx.env,
|
|
22
|
+
trackerDir: ctx.trackerPaths.trackerDir,
|
|
23
|
+
});
|
|
24
|
+
return baseProbe(this, state);
|
|
25
|
+
},
|
|
26
|
+
async install(ctx) {
|
|
27
|
+
if (!(await isDir(ctx.hermes.hermesHome))) {
|
|
28
|
+
return action(this, "skipped", false, "Hermes home not found");
|
|
29
|
+
}
|
|
30
|
+
const result = await installHermesPlugin({
|
|
31
|
+
home: ctx.home,
|
|
32
|
+
env: ctx.env,
|
|
33
|
+
trackerDir: ctx.trackerPaths.trackerDir,
|
|
34
|
+
});
|
|
35
|
+
return action(
|
|
36
|
+
this,
|
|
37
|
+
result.changed ? "installed" : "set",
|
|
38
|
+
Boolean(result.changed),
|
|
39
|
+
result.changed ? "Plugin installed" : "Plugin already installed",
|
|
40
|
+
);
|
|
41
|
+
},
|
|
42
|
+
async uninstall(ctx) {
|
|
43
|
+
if (!(await isDir(ctx.hermes.hermesHome))) {
|
|
44
|
+
return action(this, "skipped", false, "Hermes home not found", {
|
|
45
|
+
skippedReason: "hermes-home-missing",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const result = await removeHermesPlugin({
|
|
49
|
+
home: ctx.home,
|
|
50
|
+
env: ctx.env,
|
|
51
|
+
trackerDir: ctx.trackerPaths.trackerDir,
|
|
52
|
+
});
|
|
53
|
+
if (result.removed) {
|
|
54
|
+
return action(this, "removed", true, result.pluginDir || ctx.hermes.pluginDir);
|
|
55
|
+
}
|
|
56
|
+
if (result.skippedReason === "plugin-missing") {
|
|
57
|
+
return action(this, "unchanged", false, "no change", {
|
|
58
|
+
skippedReason: result.skippedReason,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (result.skippedReason === "unexpected-content") {
|
|
62
|
+
return action(this, "skipped", false, "unexpected content", {
|
|
63
|
+
skippedReason: result.skippedReason,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return action(this, "skipped", false, "Hermes home not found", {
|
|
67
|
+
skippedReason: result.skippedReason || "hermes-home-missing",
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
renderStatusValue(probe) {
|
|
71
|
+
if (probe.status === "ready") return "set";
|
|
72
|
+
if (probe.status === "not_installed") return "unset";
|
|
73
|
+
return probe.status;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function baseProbe(descriptor, values) {
|
|
78
|
+
return {
|
|
79
|
+
name: descriptor.name,
|
|
80
|
+
summaryLabel: descriptor.summaryLabel,
|
|
81
|
+
statusLabel: descriptor.statusLabel,
|
|
82
|
+
configured: false,
|
|
83
|
+
...values,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function action(descriptor, status, changed, detail, extras = {}) {
|
|
88
|
+
return {
|
|
89
|
+
name: descriptor.name,
|
|
90
|
+
label: descriptor.summaryLabel,
|
|
91
|
+
status,
|
|
92
|
+
changed,
|
|
93
|
+
detail,
|
|
94
|
+
...extras,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -3,7 +3,9 @@ const codex = require("./codex");
|
|
|
3
3
|
const everyCode = require("./every-code");
|
|
4
4
|
const claude = require("./claude");
|
|
5
5
|
const gemini = require("./gemini");
|
|
6
|
+
const kimi = require("./kimi");
|
|
6
7
|
const opencode = require("./opencode");
|
|
8
|
+
const hermes = require("./hermes");
|
|
7
9
|
const openclawSession = require("./openclaw-session");
|
|
8
10
|
|
|
9
11
|
const INTEGRATIONS = [
|
|
@@ -11,7 +13,9 @@ const INTEGRATIONS = [
|
|
|
11
13
|
everyCode,
|
|
12
14
|
claude,
|
|
13
15
|
gemini,
|
|
16
|
+
kimi,
|
|
14
17
|
opencode,
|
|
18
|
+
hermes,
|
|
15
19
|
openclawSession,
|
|
16
20
|
];
|
|
17
21
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const {
|
|
2
|
+
isKimiHookConfigured,
|
|
3
|
+
upsertKimiHook,
|
|
4
|
+
removeKimiHook,
|
|
5
|
+
probeKimiHook,
|
|
6
|
+
} = require("../kimi-config");
|
|
7
|
+
const { isDir, isFile } = require("./utils");
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
name: "kimi",
|
|
11
|
+
summaryLabel: "Kimi",
|
|
12
|
+
statusLabel: "Kimi hooks",
|
|
13
|
+
async probe(ctx) {
|
|
14
|
+
const hasConfigDir = await isDir(ctx.kimi.configDir);
|
|
15
|
+
if (!hasConfigDir) {
|
|
16
|
+
return baseProbe(this, { status: "not_installed", detail: "Config not found" });
|
|
17
|
+
}
|
|
18
|
+
const hasConfigFile = await isFile(ctx.kimi.configPath);
|
|
19
|
+
if (!hasConfigFile) {
|
|
20
|
+
return baseProbe(this, {
|
|
21
|
+
status: "not_installed",
|
|
22
|
+
detail: "config.toml not found",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const state = await probeKimiHook({
|
|
26
|
+
configPath: ctx.kimi.configPath,
|
|
27
|
+
hookCommand: ctx.kimi.hookCommand,
|
|
28
|
+
});
|
|
29
|
+
if (state.configured) {
|
|
30
|
+
return baseProbe(this, {
|
|
31
|
+
status: "ready",
|
|
32
|
+
detail: "Hooks installed",
|
|
33
|
+
configured: true,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return baseProbe(this, {
|
|
37
|
+
status: state.drifted ? "drifted" : "not_installed",
|
|
38
|
+
detail: state.drifted
|
|
39
|
+
? "Run vibeusage init to reconcile hooks"
|
|
40
|
+
: "Run vibeusage init to install hooks",
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
async install(ctx) {
|
|
44
|
+
if (!(await isDir(ctx.kimi.configDir))) {
|
|
45
|
+
return action(this, "skipped", false, "Config not found");
|
|
46
|
+
}
|
|
47
|
+
if (!(await isFile(ctx.kimi.configPath))) {
|
|
48
|
+
return action(this, "skipped", false, "config.toml not found");
|
|
49
|
+
}
|
|
50
|
+
const result = await upsertKimiHook({
|
|
51
|
+
configPath: ctx.kimi.configPath,
|
|
52
|
+
hookCommand: ctx.kimi.hookCommand,
|
|
53
|
+
});
|
|
54
|
+
return action(
|
|
55
|
+
this,
|
|
56
|
+
result.changed ? "installed" : "set",
|
|
57
|
+
Boolean(result.changed),
|
|
58
|
+
result.changed ? "Hooks installed" : "Hooks already installed",
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
async uninstall(ctx) {
|
|
62
|
+
if (!(await isDir(ctx.kimi.configDir))) {
|
|
63
|
+
return action(this, "skipped", false, "config dir not found");
|
|
64
|
+
}
|
|
65
|
+
const result = await removeKimiHook({ configPath: ctx.kimi.configPath });
|
|
66
|
+
if (result.removed) {
|
|
67
|
+
return action(this, "removed", true, ctx.kimi.configPath);
|
|
68
|
+
}
|
|
69
|
+
if (result.skippedReason === "hook-missing") {
|
|
70
|
+
return action(this, "unchanged", false, "no change", {
|
|
71
|
+
skippedReason: result.skippedReason,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return action(this, "skipped", false, "config.toml not found");
|
|
75
|
+
},
|
|
76
|
+
renderStatusValue(probe) {
|
|
77
|
+
if (probe.status === "ready") return "set";
|
|
78
|
+
if (probe.status === "not_installed") return "unset";
|
|
79
|
+
return probe.status;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function baseProbe(descriptor, values) {
|
|
84
|
+
return {
|
|
85
|
+
name: descriptor.name,
|
|
86
|
+
summaryLabel: descriptor.summaryLabel,
|
|
87
|
+
statusLabel: descriptor.statusLabel,
|
|
88
|
+
configured: false,
|
|
89
|
+
...values,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function action(descriptor, status, changed, detail, extras = {}) {
|
|
94
|
+
return {
|
|
95
|
+
name: descriptor.name,
|
|
96
|
+
label: descriptor.summaryLabel,
|
|
97
|
+
status,
|
|
98
|
+
changed,
|
|
99
|
+
detail,
|
|
100
|
+
...extras,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Expose for tests / diagnostics that want the probe-only check.
|
|
105
|
+
module.exports.isKimiHookConfigured = isKimiHookConfigured;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
const os = require("node:os");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const fs = require("node:fs/promises");
|
|
4
|
+
|
|
5
|
+
const { ensureDir } = require("./fs");
|
|
6
|
+
|
|
7
|
+
const DEFAULT_EVENTS = ["SessionEnd", "Stop"];
|
|
8
|
+
const DEFAULT_TIMEOUT = 30;
|
|
9
|
+
const MANAGED_START = "# --- vibeusage Kimi hooks START (managed, do not edit) ---";
|
|
10
|
+
const MANAGED_END = "# --- vibeusage Kimi hooks END ---";
|
|
11
|
+
|
|
12
|
+
function resolveKimiConfigDir({ home = os.homedir(), env = process.env } = {}) {
|
|
13
|
+
const explicit = typeof env.KIMI_HOME === "string" ? env.KIMI_HOME.trim() : "";
|
|
14
|
+
if (explicit) return path.resolve(explicit);
|
|
15
|
+
return path.join(home, ".kimi");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveKimiConfigPath({ configDir }) {
|
|
19
|
+
return path.join(configDir, "config.toml");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildKimiHookCommand(notifyPath) {
|
|
23
|
+
const cmd = typeof notifyPath === "string" ? notifyPath : "";
|
|
24
|
+
return `/usr/bin/env node ${quoteArg(cmd)} --source=kimi`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function upsertKimiHook({
|
|
28
|
+
configPath,
|
|
29
|
+
hookCommand,
|
|
30
|
+
events = DEFAULT_EVENTS,
|
|
31
|
+
timeout = DEFAULT_TIMEOUT,
|
|
32
|
+
}) {
|
|
33
|
+
const existing = await readFileOrEmpty(configPath);
|
|
34
|
+
const normalizedEvents = normalizeEvents(events);
|
|
35
|
+
const nextBlock = buildManagedBlock({
|
|
36
|
+
hookCommand,
|
|
37
|
+
events: normalizedEvents,
|
|
38
|
+
timeout: normalizeTimeout(timeout),
|
|
39
|
+
});
|
|
40
|
+
const { content, changed } = replaceManagedBlock(existing, nextBlock);
|
|
41
|
+
if (!changed) return { changed: false, backupPath: null };
|
|
42
|
+
const backupPath = await writeWithBackup({ configPath, content });
|
|
43
|
+
return { changed: true, backupPath };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function removeKimiHook({ configPath }) {
|
|
47
|
+
const existing = await readFileOrEmpty(configPath);
|
|
48
|
+
if (!existing) return { removed: false, skippedReason: "config-missing", backupPath: null };
|
|
49
|
+
const { content, changed } = stripManagedBlock(existing);
|
|
50
|
+
if (!changed) return { removed: false, skippedReason: "hook-missing", backupPath: null };
|
|
51
|
+
const backupPath = await writeWithBackup({ configPath, content });
|
|
52
|
+
return { removed: true, skippedReason: null, backupPath };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function isKimiHookConfigured({
|
|
56
|
+
configPath,
|
|
57
|
+
hookCommand,
|
|
58
|
+
events = DEFAULT_EVENTS,
|
|
59
|
+
timeout = DEFAULT_TIMEOUT,
|
|
60
|
+
}) {
|
|
61
|
+
const probe = await probeKimiHook({ configPath, hookCommand, events, timeout });
|
|
62
|
+
return probe.configured;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function probeKimiHook({
|
|
66
|
+
configPath,
|
|
67
|
+
hookCommand,
|
|
68
|
+
events = DEFAULT_EVENTS,
|
|
69
|
+
timeout = DEFAULT_TIMEOUT,
|
|
70
|
+
}) {
|
|
71
|
+
const existing = await readFileOrEmpty(configPath);
|
|
72
|
+
if (!existing) {
|
|
73
|
+
return { configured: false, anyPresent: false, drifted: false };
|
|
74
|
+
}
|
|
75
|
+
const block = extractManagedBlock(existing);
|
|
76
|
+
if (!block) {
|
|
77
|
+
return { configured: false, anyPresent: false, drifted: false };
|
|
78
|
+
}
|
|
79
|
+
const expected = buildManagedBlock({
|
|
80
|
+
hookCommand,
|
|
81
|
+
events: normalizeEvents(events),
|
|
82
|
+
timeout: normalizeTimeout(timeout),
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
configured: block === expected,
|
|
86
|
+
anyPresent: true,
|
|
87
|
+
drifted: block !== expected,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildManagedBlock({ hookCommand, events, timeout }) {
|
|
92
|
+
const lines = [MANAGED_START];
|
|
93
|
+
for (const event of events) {
|
|
94
|
+
lines.push(
|
|
95
|
+
"[[hooks]]",
|
|
96
|
+
`event = ${tomlString(event)}`,
|
|
97
|
+
`command = ${tomlString(hookCommand)}`,
|
|
98
|
+
`timeout = ${timeout}`,
|
|
99
|
+
);
|
|
100
|
+
lines.push("");
|
|
101
|
+
}
|
|
102
|
+
lines.push(MANAGED_END);
|
|
103
|
+
return lines.join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function replaceManagedBlock(existing, nextBlock) {
|
|
107
|
+
const startIdx = existing.indexOf(MANAGED_START);
|
|
108
|
+
const endIdx = existing.indexOf(MANAGED_END);
|
|
109
|
+
|
|
110
|
+
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
111
|
+
const blockEnd = endIdx + MANAGED_END.length;
|
|
112
|
+
const current = existing.slice(startIdx, blockEnd);
|
|
113
|
+
if (current === nextBlock) return { content: existing, changed: false };
|
|
114
|
+
const before = existing.slice(0, startIdx);
|
|
115
|
+
let after = existing.slice(blockEnd);
|
|
116
|
+
if (after.startsWith("\n")) after = after.slice(1);
|
|
117
|
+
const beforeTrimmed = before.replace(/\n+$/, "");
|
|
118
|
+
const prefix = beforeTrimmed.length > 0 ? `${beforeTrimmed}\n\n` : "";
|
|
119
|
+
const suffix = after.length > 0 ? `\n\n${after.replace(/^\n+/, "")}` : "\n";
|
|
120
|
+
return { content: `${prefix}${nextBlock}${suffix}`, changed: true };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const base = existing.replace(/\n+$/, "");
|
|
124
|
+
const prefix = base.length > 0 ? `${base}\n\n` : "";
|
|
125
|
+
return { content: `${prefix}${nextBlock}\n`, changed: true };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function stripManagedBlock(existing) {
|
|
129
|
+
const startIdx = existing.indexOf(MANAGED_START);
|
|
130
|
+
const endIdx = existing.indexOf(MANAGED_END);
|
|
131
|
+
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
|
|
132
|
+
return { content: existing, changed: false };
|
|
133
|
+
}
|
|
134
|
+
const blockEnd = endIdx + MANAGED_END.length;
|
|
135
|
+
const before = existing.slice(0, startIdx).replace(/\n+$/, "");
|
|
136
|
+
let after = existing.slice(blockEnd);
|
|
137
|
+
after = after.replace(/^\n+/, "");
|
|
138
|
+
if (before.length === 0 && after.length === 0) {
|
|
139
|
+
return { content: "", changed: true };
|
|
140
|
+
}
|
|
141
|
+
if (before.length === 0) return { content: `${after.endsWith("\n") ? after : `${after}\n`}`, changed: true };
|
|
142
|
+
if (after.length === 0) return { content: `${before}\n`, changed: true };
|
|
143
|
+
return { content: `${before}\n\n${after.endsWith("\n") ? after : `${after}\n`}`, changed: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function extractManagedBlock(existing) {
|
|
147
|
+
const startIdx = existing.indexOf(MANAGED_START);
|
|
148
|
+
const endIdx = existing.indexOf(MANAGED_END);
|
|
149
|
+
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return null;
|
|
150
|
+
const blockEnd = endIdx + MANAGED_END.length;
|
|
151
|
+
return existing.slice(startIdx, blockEnd);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeEvents(raw) {
|
|
155
|
+
const values = Array.isArray(raw) ? raw : [raw];
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const value of values) {
|
|
158
|
+
if (typeof value !== "string") continue;
|
|
159
|
+
const normalized = value.trim();
|
|
160
|
+
if (!normalized || out.includes(normalized)) continue;
|
|
161
|
+
out.push(normalized);
|
|
162
|
+
}
|
|
163
|
+
return out.length > 0 ? out : DEFAULT_EVENTS.slice();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeTimeout(value) {
|
|
167
|
+
const n = Number(value);
|
|
168
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_TIMEOUT;
|
|
169
|
+
return Math.floor(n);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function tomlString(value) {
|
|
173
|
+
const v = typeof value === "string" ? value : String(value ?? "");
|
|
174
|
+
return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function quoteArg(value) {
|
|
178
|
+
const v = typeof value === "string" ? value : "";
|
|
179
|
+
if (!v) return '""';
|
|
180
|
+
if (/^[A-Za-z0-9_\-./:@]+$/.test(v)) return v;
|
|
181
|
+
return `"${v.replace(/"/g, '\\"')}"`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function readFileOrEmpty(filePath) {
|
|
185
|
+
try {
|
|
186
|
+
return await fs.readFile(filePath, "utf8");
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (err && err.code === "ENOENT") return "";
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function writeWithBackup({ configPath, content }) {
|
|
194
|
+
await ensureDir(path.dirname(configPath));
|
|
195
|
+
let backupPath = null;
|
|
196
|
+
try {
|
|
197
|
+
const st = await fs.stat(configPath);
|
|
198
|
+
if (st && st.isFile()) {
|
|
199
|
+
backupPath = `${configPath}.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
200
|
+
await fs.copyFile(configPath, backupPath);
|
|
201
|
+
}
|
|
202
|
+
} catch (_e) {
|
|
203
|
+
// no existing file
|
|
204
|
+
}
|
|
205
|
+
await fs.writeFile(configPath, content, "utf8");
|
|
206
|
+
return backupPath;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
DEFAULT_EVENTS,
|
|
211
|
+
DEFAULT_TIMEOUT,
|
|
212
|
+
MANAGED_START,
|
|
213
|
+
MANAGED_END,
|
|
214
|
+
resolveKimiConfigDir,
|
|
215
|
+
resolveKimiConfigPath,
|
|
216
|
+
buildKimiHookCommand,
|
|
217
|
+
upsertKimiHook,
|
|
218
|
+
removeKimiHook,
|
|
219
|
+
isKimiHookConfigured,
|
|
220
|
+
probeKimiHook,
|
|
221
|
+
};
|
|
@@ -2,7 +2,7 @@ const fs = require("node:fs/promises");
|
|
|
2
2
|
const os = require("node:os");
|
|
3
3
|
const path = require("node:path");
|
|
4
4
|
|
|
5
|
-
const {
|
|
5
|
+
const { parseOpencodeIncremental } = require("./rollout");
|
|
6
6
|
|
|
7
7
|
const BUCKET_SEPARATOR = "|";
|
|
8
8
|
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
@@ -31,7 +31,6 @@ function addTotals(target, delta) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
async function buildLocalHourlyTotals({ storageDir, source = "opencode" }) {
|
|
34
|
-
const messageFiles = await listOpencodeMessageFiles(storageDir);
|
|
35
34
|
const opencodeDbPath = path.resolve(storageDir, "..", "opencode.db");
|
|
36
35
|
const queuePath = path.join(
|
|
37
36
|
os.tmpdir(),
|
|
@@ -39,7 +38,7 @@ async function buildLocalHourlyTotals({ storageDir, source = "opencode" }) {
|
|
|
39
38
|
);
|
|
40
39
|
const cursors = { version: 1, files: {}, hourly: null, opencode: null, opencodeSqlite: null };
|
|
41
40
|
|
|
42
|
-
await parseOpencodeIncremental({
|
|
41
|
+
await parseOpencodeIncremental({ opencodeDbPath, cursors, queuePath, source });
|
|
43
42
|
await fs.rm(queuePath, { force: true }).catch(() => {});
|
|
44
43
|
|
|
45
44
|
const byHour = new Map();
|