genshin-mods 4.3.3 → 4.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/.claude-plugin/plugin.json +10 -2
- package/README.md +13 -28
- package/package.json +2 -2
- package/src/models/ModName.ts +9 -0
- package/src/models/SinFigures.ts +4 -0
- package/src/models/SinName.ts +12 -0
- package/src/models/SinPosterior.ts +4 -0
- package/src/models/SinScore.ts +7 -0
- package/src/models/SinStatusKind.ts +8 -0
- package/src/models/SinsResult.ts +6 -0
- package/src/models/TaskCall.ts +1 -7
- package/src/models/TaskStatus.ts +6 -0
- package/src/models/UsageWindow.ts +1 -3
- package/src/register.ts +7 -3
- package/src/services/InitialState.ts +7 -2
- package/src/services/ModDescriptionMap.ts +10 -6
- package/src/services/band/registerBand.ts +51 -4
- package/src/services/constants.ts +3 -8
- package/src/services/readHome.ts +7 -0
- package/src/services/registerLifecycle.ts +90 -24
- package/src/services/resin/RateLimitKindUsageWindowMap.ts +2 -10
- package/src/services/resin/formatResetsAt.ts +1 -1
- package/src/services/resin/getReserveSummary.ts +4 -4
- package/src/services/resin/getReserveWindow.ts +8 -13
- package/src/services/resin/reserveText.ts +4 -4
- package/src/services/sins/SinColorMap.ts +12 -0
- package/src/services/sins/SinStatusTextMap.ts +10 -0
- package/src/services/sins/appendSinEvidence.ts +18 -0
- package/src/services/sins/constants.ts +33 -0
- package/src/services/sins/getBarCells.ts +8 -0
- package/src/services/sins/getPosteriorTrail.ts +21 -0
- package/src/services/sins/getScoreAndSpread.ts +8 -0
- package/src/services/sins/getSinQuestions.ts +28 -0
- package/src/services/sins/getSinScores.ts +17 -0
- package/src/services/sins/getTrend.ts +13 -0
- package/src/services/sins/parseSinsResult.ts +5 -0
- package/src/services/sins/pickTypeSafeKey.ts +4 -0
- package/src/services/sins/readIsLightTheme.ts +8 -0
- package/src/services/sins/readSinStatus.ts +20 -0
- package/src/services/sins/readTypeSafeKey.ts +10 -0
- package/src/services/sins/registerSins.ts +56 -0
- package/src/services/sins/sinsScript.ts +47 -0
- package/src/services/ward/resolveRecordsPath.ts +2 -1
- package/types/index.d.ts +11 -5
|
@@ -2,11 +2,19 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "genshin-mods",
|
|
4
4
|
"displayName": "Genshin Mods",
|
|
5
|
-
"description": "Six Claude Code mods: five under one band above the prompt, waypoints (the next steps, one press each), resin (cache, context, limits and cost, with warm, compact and a one-click handoff), veil (recording mode), commission (a goal's task list and clock)
|
|
5
|
+
"description": "Six Claude Code mods, each switched by its own command: five under one band above the prompt, waypoints (the next steps, one press each), resin (cache, context, limits and cost, with warm, compact and a one-click handoff), veil (recording mode), commission (a goal's task list and clock), ward (a question before editing a file another session just changed, and a refusal of a git command over the whole index while another session shares the checkout), and pulse (the machine watcher). Two guards run in every session without a command: a delegation guard that nudges a run of lookups toward a haiku agent, and a disk-scan guard that refuses a Bash scan from a root or home folder.",
|
|
6
6
|
"author": { "name": "Esposter", "url": "https://github.com/Esposter" },
|
|
7
7
|
"homepage": "https://github.com/Esposter/Esposter/tree/main/openspec/specs/infra/claude-interface/genshin-mods",
|
|
8
8
|
"repository": "https://github.com/Esposter/Esposter",
|
|
9
9
|
"license": "Apache-2.0",
|
|
10
10
|
"keywords": ["cache", "collision", "genshin", "handoff", "mods", "recording", "tasks"],
|
|
11
|
-
"types": "./types/index.d.ts"
|
|
11
|
+
"types": "./types/index.d.ts",
|
|
12
|
+
"userConfig": {
|
|
13
|
+
"typesafe_key": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"title": "TypeSafe key",
|
|
16
|
+
"description": "A TypeSafe API key for sins: with it, each prompt's seven sins are scored by one typed decision and drawn in a pane. The same option as genshin-persona's, stored in the credential store, never in a settings file.",
|
|
17
|
+
"sensitive": true
|
|
18
|
+
}
|
|
19
|
+
}
|
|
12
20
|
}
|
package/README.md
CHANGED
|
@@ -7,20 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
A Claude Code plugin of mods: five drawn as one band above the prompt, in the session character's colour (the next steps one press away, what the session is spending with a one-press handoff, recording mode, a goal's task list and clock, and a question before editing a file another session just changed), a delegation guard that refuses any subagent not on haiku and nudges a run of lookups toward a haiku agent, a disk-scan guard that refuses a Bash scan from a root or a home folder, and a whole-index guard that refuses a git command over the whole index while another session shares the checkout.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
- **Resin** — the prompt cache's time left, the context window, the five-hour and weekly limits and the cost so far, with **Warm**, **Compact** and **Handoff**, a toast before the cache goes cold, and a usage reserve past four-fifths of a limit window, or past half of one spent ahead of an even pace, that has the session clean up on cheap agents, then wind down, until the window resets, refusing a workflow at launch meanwhile.
|
|
12
|
-
- **Veil** — recording mode: emails, amounts, phone numbers and secrets shown as placeholders while the model still reads the real values.
|
|
13
|
-
- **Commission** — a goal meter over the session's task list: the goal, tasks done, the share complete and the minutes since it started.
|
|
14
|
-
- **Ward** — before an edit to a file another session changed in the last half hour, a question: proceed, move to a worktree, or cancel.
|
|
15
|
-
- **Delegation guard** — every subagent launch not on haiku refused, a fork included, and after every third lookup in a row with no decision between them, a note on that call's result that the chain goes to a haiku agent as one bounded question.
|
|
16
|
-
- **Disk-scan guard** — a Bash `find`, `du`, `grep -r` or `ls -R` that starts at `/`, a drive, `~` or a home folder is refused, with the known place to look for the file.
|
|
17
|
-
- **Pulse** — keeps the machine watcher running from any session with the plugin, so its heartbeat and the machine's state keep being written.
|
|
18
|
-
- **Whole-index guard** — while another session edits this checkout, a Bash `git add .`, `git add -A`, `git reset` with no path, `git stash`, `git checkout .` or `git clean` is refused, with the path-scoped form to use instead.
|
|
10
|
+
Design notes live in [the specs](https://github.com/Esposter/Esposter/tree/main/openspec/specs/infra/claude-interface/genshin-mods).
|
|
19
11
|
|
|
20
12
|
## Table of Contents
|
|
21
13
|
|
|
22
14
|
- 🚀 [Getting Started](#getting-started)
|
|
23
|
-
-
|
|
15
|
+
- ⌨️ [Commands](#commands)
|
|
24
16
|
- ⚖️ [License](#license)
|
|
25
17
|
|
|
26
18
|
---
|
|
@@ -34,6 +26,12 @@ claude plugin marketplace add Esposter/Esposter
|
|
|
34
26
|
claude plugin install genshin-mods@esposter
|
|
35
27
|
```
|
|
36
28
|
|
|
29
|
+
`/sins` scores with [TypeSafe](https://typesafe.ai), so it needs a key: the plugin's `typesafe_key` option, the same option [`genshin-persona`](https://github.com/Esposter/Esposter/tree/main/packages/genshin-persona) has, or the `TYPESAFE_API_KEY` variable when the option is empty:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
claude plugin install genshin-mods@esposter --config typesafe_key=<key>
|
|
33
|
+
```
|
|
34
|
+
|
|
37
35
|
A clone of this repository needs neither command: `.agents/settings.json` enables the plugin at project scope once you trust the repository. With [`genshin-persona`](https://github.com/Esposter/Esposter/tree/main/packages/genshin-persona) installed beside it, the band takes the session character's colour; without it, the game's interface gold.
|
|
38
36
|
|
|
39
37
|
Developing the plugin from a checkout loads the directory itself, with the installed copy disabled so only one of them answers:
|
|
@@ -43,35 +41,22 @@ claude plugin disable genshin-mods@esposter
|
|
|
43
41
|
claude --plugin-dir packages/genshin-mods
|
|
44
42
|
```
|
|
45
43
|
|
|
46
|
-
## <a name="
|
|
47
|
-
|
|
48
|
-
We highly recommend you take a look at the [documentation](https://github.com/Esposter/Esposter/tree/main/openspec/specs/infra/claude-interface/genshin-mods) to level up.
|
|
49
|
-
|
|
50
|
-
### Command reference
|
|
44
|
+
## <a name="commands">⌨️ Commands</a>
|
|
51
45
|
|
|
52
46
|
Each mod is switched by its own slash command; bare, it flips, and the choice is kept across sessions:
|
|
53
47
|
|
|
54
48
|
| Command | What it does |
|
|
55
49
|
| :------------------------ | :------------------------------------------------------------------------------------------------------------------------------ |
|
|
56
|
-
| `/waypoints [on \| off]` | Suggests the next steps after each answered turn, or stops |
|
|
57
|
-
| `/resin [on \| off]` | Shows the cache, context, limits and cost row and its warning toast |
|
|
58
|
-
| `/veil [on \| off]` | Turns recording mode on or off; off by default |
|
|
59
50
|
| `/commission [on \| off]` | Shows the goal meter while a turn works through a task list |
|
|
60
51
|
| `/pulse [on \| off]` | Keeps the machine watcher running, or stops |
|
|
52
|
+
| `/resin [on \| off]` | Shows the cache, context, limits and cost row and its warning toast |
|
|
53
|
+
| `/sins [on \| off]` | Scores each prompt's seven sins with TypeSafe, drawn as bars in a pane; needs the `typesafe_key` option or `TYPESAFE_API_KEY` |
|
|
54
|
+
| `/veil [on \| off]` | Turns recording mode on or off; off by default |
|
|
61
55
|
| `/ward [on \| off]` | Asks before an edit to a file another session just changed, and refuses a whole-index git command while one shares the checkout |
|
|
56
|
+
| `/waypoints [on \| off]` | Suggests the next steps after each answered turn, or stops |
|
|
62
57
|
|
|
63
58
|
With the band focused (a click or `ctrl+x tab`), `w`, `c` and `h` warm, compact and hand off, `1` to `3` send a waypoint and `g` opens or closes the whole task list.
|
|
64
59
|
|
|
65
|
-
### Commands
|
|
66
|
-
|
|
67
|
-
Run from `packages/genshin-mods/`:
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
pnpm test # vitest watch mode (coverage is run from the repo root)
|
|
71
|
-
pnpm validate # claude plugin validate: the engine's own reading of the hooks module
|
|
72
|
-
pnpm lint:fix # auto-fix lint
|
|
73
|
-
```
|
|
74
|
-
|
|
75
60
|
## <a name="license">⚖️ License</a>
|
|
76
61
|
|
|
77
62
|
This project is licensed under the [Apache-2.0 license](https://github.com/Esposter/Esposter/blob/main/LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genshin-mods",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"description": "A Claude Code plugin of mods: five under one band above the prompt (the next steps, the cache and usage with a one-click handoff, recording mode, a goal's task list and a question before editing a file another session just changed) a delegation guard that nudges a run of lookups toward a haiku agent, a disk-scan guard that refuses a Bash scan from a root or a home folder, and a whole-index guard that refuses a git command over the whole index while another session shares the checkout.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -36,5 +36,5 @@
|
|
|
36
36
|
"test": "vitest",
|
|
37
37
|
"validate": "claude plugin validate ."
|
|
38
38
|
},
|
|
39
|
-
"gitHead": "
|
|
39
|
+
"gitHead": "e1a416c4b91da69cc2aa259b50a4d656f204d272"
|
|
40
40
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export enum SinName {
|
|
2
|
+
Envy = "envy",
|
|
3
|
+
Gluttony = "gluttony",
|
|
4
|
+
Greed = "greed",
|
|
5
|
+
Lust = "lust",
|
|
6
|
+
Pride = "pride",
|
|
7
|
+
Sloth = "sloth",
|
|
8
|
+
Wrath = "wrath",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The seven sins, in the order the pane draws them. Each is one `score` question Jev answers about a prompt
|
|
12
|
+
export const SIN_NAMES: readonly SinName[] = Object.values(SinName);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// What the sins mod last found: its scores when the call answered, or why it could not score the last prompt
|
|
2
|
+
export enum SinStatusKind {
|
|
3
|
+
Answered = "answered",
|
|
4
|
+
Failed = "failed",
|
|
5
|
+
NodeTooOld = "node-too-old",
|
|
6
|
+
NoKey = "no-key",
|
|
7
|
+
Unchecked = "unchecked",
|
|
8
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// What the plugin's node script answers: the seven answers, or why none were recorded
|
|
2
|
+
import type { SinStatusKind } from "./SinStatusKind";
|
|
3
|
+
|
|
4
|
+
export type SinsResult =
|
|
5
|
+
| { answers: Record<string, { confidence: number; score: number }>; result: SinStatusKind.Answered }
|
|
6
|
+
| { reason?: string; result: SinStatusKind.Failed | SinStatusKind.NodeTooOld | SinStatusKind.NoKey };
|
package/src/models/TaskCall.ts
CHANGED
|
@@ -3,12 +3,6 @@ import type { TaskTool } from "./TaskTool";
|
|
|
3
3
|
|
|
4
4
|
// One task tool call with what its result says, in the shapes the engine's own tools declare
|
|
5
5
|
export type TaskCall =
|
|
6
|
-
| {
|
|
7
|
-
id: string;
|
|
8
|
-
// oxlint-disable-next-line literal-union/no-string-literal-union -- The engine's own TaskUpdate statuses
|
|
9
|
-
status?: "deleted" | CommissionTask["status"];
|
|
10
|
-
subject?: string;
|
|
11
|
-
tool: TaskTool.TaskUpdate;
|
|
12
|
-
}
|
|
6
|
+
| { id: string; status?: "deleted" | CommissionTask["status"]; subject?: string; tool: TaskTool.TaskUpdate }
|
|
13
7
|
| { id: string; subject: string; tool: TaskTool.TaskCreate }
|
|
14
8
|
| { todos: { content: string; status: CommissionTask["status"] }[]; tool: TaskTool.TodoWrite };
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
export interface UsageWindow {
|
|
2
2
|
// The window's length, which its reset time is counted back from
|
|
3
3
|
durationMs: number;
|
|
4
|
-
// The usage line the window's maintenance
|
|
4
|
+
// The usage line the window's maintenance line starts at
|
|
5
5
|
maintenancePercentage: number;
|
|
6
6
|
// The window's name as the reserve's note spells it
|
|
7
7
|
name: string;
|
|
8
|
-
// The usage line the window's wind-down starts at
|
|
9
|
-
windDownPercentage: number;
|
|
10
8
|
}
|
package/src/register.ts
CHANGED
|
@@ -6,17 +6,21 @@ import { registerDelegation } from "./services/delegation/registerDelegation";
|
|
|
6
6
|
import { registerDiskScan } from "./services/diskScan/registerDiskScan";
|
|
7
7
|
import { registerLifecycle } from "./services/registerLifecycle";
|
|
8
8
|
import { registerReserveGate } from "./services/resin/registerReserveGate";
|
|
9
|
+
import { registerSins } from "./services/sins/registerSins";
|
|
9
10
|
import { registerVeil } from "./services/veil/registerVeil";
|
|
10
11
|
import { registerWard } from "./services/ward/registerWard";
|
|
11
12
|
import { registerWholeIndex } from "./services/ward/registerWholeIndex";
|
|
12
13
|
|
|
13
|
-
export const register: Register = (on) => {
|
|
14
|
-
|
|
14
|
+
export const register: Register = (on, options) => {
|
|
15
|
+
// The plugin's typesafe_key option, empty when it is not set: the sins readers fall back to the TYPESAFE_API_KEY variable
|
|
16
|
+
const typeSafeKey = typeof options.typesafe_key === "string" ? options.typesafe_key : "";
|
|
17
|
+
registerBand(on, typeSafeKey);
|
|
15
18
|
registerCommission(on);
|
|
16
19
|
registerDelegation(on);
|
|
17
20
|
registerDiskScan(on);
|
|
18
|
-
registerLifecycle(on);
|
|
21
|
+
registerLifecycle(on, typeSafeKey);
|
|
19
22
|
registerReserveGate(on);
|
|
23
|
+
registerSins(on, typeSafeKey);
|
|
20
24
|
registerVeil(on);
|
|
21
25
|
registerWard(on);
|
|
22
26
|
registerWholeIndex(on);
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import type { PluginState } from "claude-code";
|
|
2
2
|
|
|
3
|
+
import { SinStatusKind } from "../models/SinStatusKind";
|
|
4
|
+
|
|
3
5
|
// Every state value's initial, read by each file's atoms: the engine traces a state reference only to an atom made in
|
|
4
6
|
// The file that uses it, so each file makes its own and this is the one place their initials are written
|
|
5
7
|
export const InitialState: PluginState["genshin-mods"] = {
|
|
6
8
|
commission: { goal: "", openedAt: 0, tasks: [] },
|
|
7
|
-
enabledMods: { commission: true, pulse: true, resin: true, veil: false, ward: true, waypoints: true },
|
|
9
|
+
enabledMods: { commission: true, pulse: true, resin: true, sins: false, veil: false, ward: true, waypoints: true },
|
|
8
10
|
isCommissionExpanded: false,
|
|
9
11
|
isHandingOff: false,
|
|
10
12
|
lastCacheRequestAt: 0,
|
|
11
13
|
lastPrompt: "",
|
|
12
14
|
lookupStreak: 0,
|
|
13
15
|
now: 0,
|
|
14
|
-
reserveWindow: { isAheadOfPace: false,
|
|
16
|
+
reserveWindow: { isAheadOfPace: false, name: "", percentage: 0, resetsAt: "" },
|
|
17
|
+
sinHistory: {},
|
|
18
|
+
sinSession: 0,
|
|
19
|
+
sinStatus: SinStatusKind.Unchecked,
|
|
15
20
|
waypoints: [],
|
|
16
21
|
};
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type { EnabledMods } from "../../types";
|
|
2
2
|
|
|
3
|
+
import { ModName } from "../models/ModName";
|
|
4
|
+
|
|
3
5
|
// Each mod's command, `/<name> [on|off]`, as the menu describes it
|
|
4
6
|
export const ModDescriptionMap: Record<keyof EnabledMods, string> = {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
[ModName.Commission]: "Show a goal's task list, how far it is and how long it has run.",
|
|
8
|
+
[ModName.Pulse]: "Keep the machine watcher running.",
|
|
9
|
+
[ModName.Resin]: "Show the cache, context, limits and cost, with warm, compact and handoff.",
|
|
10
|
+
[ModName.Sins]: "Score each prompt's seven sins with TypeSafe, drawn as bars in a pane. Needs a TypeSafe key.",
|
|
11
|
+
[ModName.Veil]: "Recording mode: show emails, amounts, phone numbers and secrets as placeholders.",
|
|
12
|
+
[ModName.Ward]:
|
|
13
|
+
"Ask before editing a file another session changed in the last half hour, and refuse a whole-index git command while one shares the checkout.",
|
|
14
|
+
[ModName.Waypoints]: "Suggest the next steps after each answer, one press each.",
|
|
11
15
|
};
|
|
12
16
|
|
|
13
17
|
export const ModNames = Object.keys(ModDescriptionMap) as (keyof EnabledMods)[];
|
|
@@ -4,9 +4,16 @@ import { atom, read, update } from "claude-code";
|
|
|
4
4
|
|
|
5
5
|
import type { EnabledMods } from "../../../types";
|
|
6
6
|
|
|
7
|
+
import { TaskStatus } from "../../models/TaskStatus";
|
|
7
8
|
import { ACCENT_COLOR, HANDOFF_QUESTION, MAX_SHOWN_TASKS, WARM_QUESTION } from "../constants";
|
|
8
9
|
import { InitialState } from "../InitialState";
|
|
9
10
|
import { getResinFigures } from "../resin/getResinFigures";
|
|
11
|
+
import { SINS_PANE_ID } from "../sins/constants";
|
|
12
|
+
import { getSinScores } from "../sins/getSinScores";
|
|
13
|
+
import { readIsLightTheme } from "../sins/readIsLightTheme";
|
|
14
|
+
import { readSinStatus } from "../sins/readSinStatus";
|
|
15
|
+
import { SinColorMap } from "../sins/SinColorMap";
|
|
16
|
+
import { SinStatusTextMap } from "../sins/SinStatusTextMap";
|
|
10
17
|
import { getCommissionSummary } from "./getCommissionSummary";
|
|
11
18
|
|
|
12
19
|
// The plugin's one band, a row per mod with something to say. `$` is followed only into functions in the file that
|
|
@@ -23,6 +30,7 @@ const lastCacheRequestAtAtom = atom(
|
|
|
23
30
|
InitialState.lastCacheRequestAt,
|
|
24
31
|
);
|
|
25
32
|
const nowAtom = atom({ key: "now", plugin: "genshin-mods" } as const, InitialState.now);
|
|
33
|
+
const sinHistoryAtom = atom({ key: "sinHistory", plugin: "genshin-mods" } as const, InitialState.sinHistory);
|
|
26
34
|
const waypointsAtom = atom({ key: "waypoints", plugin: "genshin-mods" } as const, InitialState.waypoints);
|
|
27
35
|
|
|
28
36
|
// The engine's press slot takes no promise; every action here resolves each of its outcomes itself
|
|
@@ -31,7 +39,7 @@ const press = (action: () => Promise<unknown>) => () => {
|
|
|
31
39
|
action();
|
|
32
40
|
};
|
|
33
41
|
|
|
34
|
-
const StatusMarkMap = {
|
|
42
|
+
const StatusMarkMap = { [TaskStatus.Completed]: "✓", [TaskStatus.InProgress]: "▸", [TaskStatus.Pending]: "·" } as const;
|
|
35
43
|
|
|
36
44
|
// A fork re-sends the conversation's prefix, which renews the cache from the moment it is sent, and adds no row to the
|
|
37
45
|
// Transcript
|
|
@@ -125,7 +133,7 @@ const drawCommissionRow = async (
|
|
|
125
133
|
const isExpanded = await read($, isCommissionExpandedAtom);
|
|
126
134
|
const shownTasks = isExpanded
|
|
127
135
|
? commission.tasks.slice(0, MAX_SHOWN_TASKS)
|
|
128
|
-
: commission.tasks.filter(({ status }) => status ===
|
|
136
|
+
: commission.tasks.filter(({ status }) => status === TaskStatus.InProgress);
|
|
129
137
|
const { Box, Button, Text } = $.ui.resolve(e);
|
|
130
138
|
return Box({
|
|
131
139
|
children: [
|
|
@@ -146,7 +154,7 @@ const drawCommissionRow = async (
|
|
|
146
154
|
...shownTasks.map(({ status, subject }) =>
|
|
147
155
|
Text({
|
|
148
156
|
children: ` ${StatusMarkMap[status]} ${subject}`,
|
|
149
|
-
dimColor: status !==
|
|
157
|
+
dimColor: status !== TaskStatus.InProgress,
|
|
150
158
|
wrap: "truncate-end",
|
|
151
159
|
}),
|
|
152
160
|
),
|
|
@@ -189,9 +197,47 @@ const drawWaypointsRow = async (
|
|
|
189
197
|
});
|
|
190
198
|
};
|
|
191
199
|
|
|
200
|
+
// The sins as one line, each in its colour, with the pane's own button; the pane holds the bars and the trends
|
|
201
|
+
const drawSinsRow = async (
|
|
202
|
+
$: EngineInterface,
|
|
203
|
+
e: RenderInput<"AbovePrompt">,
|
|
204
|
+
enabledMods: EnabledMods,
|
|
205
|
+
accent: string,
|
|
206
|
+
typeSafeKey: string,
|
|
207
|
+
): Promise<RenderElement | undefined> => {
|
|
208
|
+
if (!enabledMods.sins) return undefined;
|
|
209
|
+
|
|
210
|
+
const scores = getSinScores(await read($, sinHistoryAtom));
|
|
211
|
+
const isLight = await readIsLightTheme($);
|
|
212
|
+
const summary = SinStatusTextMap[await readSinStatus($, typeSafeKey)];
|
|
213
|
+
const { Box, Button, Text } = $.ui.resolve(e);
|
|
214
|
+
return Box({
|
|
215
|
+
children: [
|
|
216
|
+
Text({ bold: true, children: "Sins ", color: accent }),
|
|
217
|
+
...(summary ? [Text({ children: `${summary} `, dimColor: true })] : []),
|
|
218
|
+
...(scores.length === 0 && !summary ? [Text({ children: "no prompt scored yet ", dimColor: true })] : []),
|
|
219
|
+
...scores.map(({ name, score }) =>
|
|
220
|
+
Text({
|
|
221
|
+
children: `${name} ${Math.round(score * 100)}% `,
|
|
222
|
+
color: SinColorMap[name][isLight ? "light" : "dark"],
|
|
223
|
+
}),
|
|
224
|
+
),
|
|
225
|
+
Button({
|
|
226
|
+
hotkey: "p",
|
|
227
|
+
key: "sins-panel",
|
|
228
|
+
label: "Panel",
|
|
229
|
+
onPress: press(() => $.ui.open({ id: SINS_PANE_ID, title: "Seven sins" })),
|
|
230
|
+
plain: true,
|
|
231
|
+
}),
|
|
232
|
+
],
|
|
233
|
+
flexDirection: "row",
|
|
234
|
+
flexWrap: "wrap",
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
|
|
192
238
|
// The veil's marker first, so a recording never runs with it off unnoticed, and the engine's own band whenever no
|
|
193
239
|
// Mod has anything to say
|
|
194
|
-
export const registerBand = (on: On): void => {
|
|
240
|
+
export const registerBand = (on: On, typeSafeKey: string): void => {
|
|
195
241
|
on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
|
|
196
242
|
if (e.props.hasSurvey) return next(e);
|
|
197
243
|
|
|
@@ -202,6 +248,7 @@ export const registerBand = (on: On): void => {
|
|
|
202
248
|
drawResinRow($, e, enabledMods, accent),
|
|
203
249
|
drawCommissionRow($, e, enabledMods, accent),
|
|
204
250
|
drawWaypointsRow($, e, enabledMods, accent),
|
|
251
|
+
drawSinsRow($, e, enabledMods, accent, typeSafeKey),
|
|
205
252
|
])
|
|
206
253
|
).filter((row) => row !== undefined);
|
|
207
254
|
if (rows.length === 0 && !enabledMods.veil) return next(e);
|
|
@@ -5,11 +5,9 @@ export const CACHE_LIFETIME_MS: number = Temporal.Duration.from({ hours: 1 }).to
|
|
|
5
5
|
export const CACHE_LOW_MS: number = Temporal.Duration.from({ minutes: 10 }).total("milliseconds");
|
|
6
6
|
export const CACHE_WARNING_MS: number = Temporal.Duration.from({ minutes: 5 }).total("milliseconds");
|
|
7
7
|
export const USAGE_WARNING_PERCENTAGE = 80;
|
|
8
|
-
// The usage
|
|
8
|
+
// The usage line each window's maintenance line starts at
|
|
9
9
|
export const FIVE_HOUR_MAINTENANCE_PERCENTAGE = 80;
|
|
10
|
-
export const FIVE_HOUR_WIND_DOWN_PERCENTAGE = 90;
|
|
11
10
|
export const WEEKLY_MAINTENANCE_PERCENTAGE = 80;
|
|
12
|
-
export const WEEKLY_WIND_DOWN_PERCENTAGE = 90;
|
|
13
11
|
// A window under this share is never read for its pace, since an early reading projects wildly
|
|
14
12
|
export const PACE_FLOOR_PERCENTAGE = 50;
|
|
15
13
|
// Each window's length, which its reset time is counted back from
|
|
@@ -42,12 +40,9 @@ export const VEIL_SECTION_ID = "genshin-mods-veil";
|
|
|
42
40
|
export const VEIL_SYSTEM_SECTION =
|
|
43
41
|
"Recording mode is on: the screen is being recorded or shared. Never write an email address, a phone number, a money amount, an API key, a token or a password in a reply; write a placeholder such as [email], [phone], [amount] or [secret] instead, even when asked for the value. Tool calls still use the real values.";
|
|
44
42
|
export const RESERVE_SECTION_ID = "genshin-mods-reserve";
|
|
45
|
-
// The
|
|
46
|
-
export const RESERVE_INSTRUCTION =
|
|
47
|
-
"Until it resets, start no new agent or workflow that writes code or designs; have each running one commit what builds and end on a handoff spec; commit and push the work in hand; write every open item down with what it takes to resume it cold (its paths, what is done, the calls made, the next step); clean up idle servers, shells and monitors; keep only long-running compute going.";
|
|
48
|
-
// The maintenance tier's section ends on this instruction: the window's rest goes on cleanup a haiku agent can do cold
|
|
43
|
+
// The reserve's section ends on this instruction: the window's rest goes on cleanup a haiku agent can do cold
|
|
49
44
|
export const MAINTENANCE_INSTRUCTION =
|
|
50
|
-
"Until it resets,
|
|
45
|
+
"Until it resets, spend the rest of the window on haiku agents for cleanup: fix the findings of a code review of this window's own recent commits, drain an open sweep ledger, bring the docs and skills in line with the code changed this window, fix CI's typecheck and lint reds, and land the refactors, simplifications and optimisations the compute queue's runs surfaced; write a finding that needs judgement down as a follow-up (the follow-ups plugin's capture skill) rather than deciding it on haiku; keep the compute queue's runners going.";
|
|
51
46
|
// The tools that look something up, the tools that change files, and the shells whose command decides which it is
|
|
52
47
|
export const LOOKUP_TOOLS: readonly string[] = ["Glob", "Grep", "Read", "WebFetch"];
|
|
53
48
|
export const RESET_TOOLS: readonly string[] = ["Agent", "Edit", "NotebookEdit", "Write"];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { EngineInterface } from "claude-code";
|
|
2
|
+
|
|
3
|
+
// The person's home folder, read from the engine's environment: HOME where it is set, else the Windows profile folder
|
|
4
|
+
// oxlint-disable-next-line func-style -- A declaration, not an arrow: the hooks module's bundle follows an imported helper that takes `$` only into a function declaration
|
|
5
|
+
export async function readHome($: EngineInterface): Promise<string> {
|
|
6
|
+
return (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE")) ?? "";
|
|
7
|
+
}
|
|
@@ -4,6 +4,7 @@ import { atom, read, update } from "claude-code";
|
|
|
4
4
|
|
|
5
5
|
import type { EnabledMods } from "../../types";
|
|
6
6
|
|
|
7
|
+
import { SinStatusKind } from "../models/SinStatusKind";
|
|
7
8
|
import {
|
|
8
9
|
CACHE_WARNING_MS,
|
|
9
10
|
MACHINE_WATCHER_RETRY_MILLISECONDS,
|
|
@@ -15,11 +16,19 @@ import {
|
|
|
15
16
|
} from "./constants";
|
|
16
17
|
import { InitialState } from "./InitialState";
|
|
17
18
|
import { ModDescriptionMap, ModNames } from "./ModDescriptionMap";
|
|
19
|
+
import { readHome } from "./readHome";
|
|
18
20
|
import { getCacheRemainingMs } from "./resin/getCacheRemainingMs";
|
|
19
21
|
import { getCacheWarningDelayMs } from "./resin/getCacheWarningDelayMs";
|
|
20
22
|
import { getReserveSummary } from "./resin/getReserveSummary";
|
|
21
23
|
import { getReserveWindow } from "./resin/getReserveWindow";
|
|
22
24
|
import { reserveText } from "./resin/reserveText";
|
|
25
|
+
import { appendSinEvidence } from "./sins/appendSinEvidence";
|
|
26
|
+
import { SINS_API_URL, SINS_TIMEOUT_MS } from "./sins/constants";
|
|
27
|
+
import { getSinQuestions } from "./sins/getSinQuestions";
|
|
28
|
+
import { parseSinsResult } from "./sins/parseSinsResult";
|
|
29
|
+
import { readTypeSafeKey } from "./sins/readTypeSafeKey";
|
|
30
|
+
import { SINS_SCRIPT } from "./sins/sinsScript";
|
|
31
|
+
import { veilText } from "./veil/veilText";
|
|
23
32
|
import { parseWaypoints } from "./waypoints/parseWaypoints";
|
|
24
33
|
|
|
25
34
|
const commissionAtom = atom({ key: "commission", plugin: "genshin-mods" } as const, InitialState.commission);
|
|
@@ -36,6 +45,9 @@ const lastCacheRequestAtAtom = atom(
|
|
|
36
45
|
const lookupStreakAtom = atom({ key: "lookupStreak", plugin: "genshin-mods" } as const, InitialState.lookupStreak);
|
|
37
46
|
const nowAtom = atom({ key: "now", plugin: "genshin-mods" } as const, InitialState.now);
|
|
38
47
|
const reserveWindowAtom = atom({ key: "reserveWindow", plugin: "genshin-mods" } as const, InitialState.reserveWindow);
|
|
48
|
+
const sinHistoryAtom = atom({ key: "sinHistory", plugin: "genshin-mods" } as const, InitialState.sinHistory);
|
|
49
|
+
const sinSessionAtom = atom({ key: "sinSession", plugin: "genshin-mods" } as const, InitialState.sinSession);
|
|
50
|
+
const sinStatusAtom = atom({ key: "sinStatus", plugin: "genshin-mods" } as const, InitialState.sinStatus);
|
|
39
51
|
const waypointsAtom = atom({ key: "waypoints", plugin: "genshin-mods" } as const, InitialState.waypoints);
|
|
40
52
|
|
|
41
53
|
// The events a plugin may hook once with no matcher, each hooked here for every mod, with the commands that switch
|
|
@@ -44,6 +56,13 @@ const waypointsAtom = atom({ key: "waypoints", plugin: "genshin-mods" } as const
|
|
|
44
56
|
let warnedLastCacheRequestAt = 0;
|
|
45
57
|
let isTurnRunning = false;
|
|
46
58
|
|
|
59
|
+
// The engine's timer and hook slots take no promise, so an action run from one floats. Every action run through here
|
|
60
|
+
// Resolves each of its outcomes rather than rejecting, which is what lets the floating stand
|
|
61
|
+
const runDetached = (action: () => Promise<void>): void => {
|
|
62
|
+
// oxlint-disable-next-line typescript/no-floating-promises -- The engine's timer and hook slots take no promise, and every action run through here resolves each of its outcomes
|
|
63
|
+
action();
|
|
64
|
+
};
|
|
65
|
+
|
|
47
66
|
// The cache goes cold at a known instant, so its warning is one timer armed for that instant at each turn's end, from the
|
|
48
67
|
// Request that last read or wrote the cache, rather than a check on a clock. The timer warns once for that request, and
|
|
49
68
|
// Stands down while a turn runs, since that turn's end arms it again. A timer left from an earlier turn finds the cache
|
|
@@ -71,8 +90,7 @@ const armCacheWarning = async ($: EngineInterface) => {
|
|
|
71
90
|
if (delayMs === undefined) return;
|
|
72
91
|
|
|
73
92
|
$.clock.after(delayMs, () => {
|
|
74
|
-
|
|
75
|
-
warnCache($);
|
|
93
|
+
runDetached(() => warnCache($));
|
|
76
94
|
});
|
|
77
95
|
};
|
|
78
96
|
|
|
@@ -81,6 +99,50 @@ const suggestWaypoints = async ($: EngineInterface) => {
|
|
|
81
99
|
if (answer.isAnswered) await update($, waypointsAtom, () => parseWaypoints(answer.text));
|
|
82
100
|
};
|
|
83
101
|
|
|
102
|
+
// The sins are scored beside the prompt's turn, never in front of it: the call and the evidence log run in the plugin's
|
|
103
|
+
// Node script, and a run that fails, or a machine that cannot open the log, records nothing and says why in the band
|
|
104
|
+
const scoreSins = async ($: EngineInterface, text: string, typeSafeKey: string) => {
|
|
105
|
+
if (!(await read($, enabledModsAtom)).sins) return;
|
|
106
|
+
|
|
107
|
+
const key = await readTypeSafeKey($, typeSafeKey);
|
|
108
|
+
if (!key) {
|
|
109
|
+
await update($, sinStatusAtom, () => SinStatusKind.NoKey);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const session = (await read($, sinSessionAtom)) || (await $.clock.now());
|
|
114
|
+
await update($, sinSessionAtom, () => session);
|
|
115
|
+
const home = await readHome($);
|
|
116
|
+
const [outcome] = await Promise.allSettled([
|
|
117
|
+
$.process.run(["node", "--input-type=module", "-e", SINS_SCRIPT], {
|
|
118
|
+
stdin: JSON.stringify({
|
|
119
|
+
apiUrl: SINS_API_URL,
|
|
120
|
+
key,
|
|
121
|
+
questions: getSinQuestions(),
|
|
122
|
+
session,
|
|
123
|
+
state: veilText(text),
|
|
124
|
+
storePath: `${home}/.claude/genshin-mods/sins.sqlite`,
|
|
125
|
+
}),
|
|
126
|
+
timeoutMs: SINS_TIMEOUT_MS,
|
|
127
|
+
}),
|
|
128
|
+
]);
|
|
129
|
+
const result =
|
|
130
|
+
outcome.status === "fulfilled" && outcome.value.exitCode === 0
|
|
131
|
+
? parseSinsResult(outcome.value.stdout)
|
|
132
|
+
: { result: SinStatusKind.Failed as const };
|
|
133
|
+
|
|
134
|
+
// A `/clear` or a resume during the run has reset the session, and a switch turned off has turned the mod off, so this
|
|
135
|
+
// Run's answers belong to nothing shown
|
|
136
|
+
const sessionNow = await read($, sinSessionAtom);
|
|
137
|
+
const { sins } = await read($, enabledModsAtom);
|
|
138
|
+
if (sessionNow !== session || !sins) return;
|
|
139
|
+
|
|
140
|
+
if (result.result === SinStatusKind.Answered)
|
|
141
|
+
await update($, sinHistoryAtom, (history) => appendSinEvidence(history, result.answers));
|
|
142
|
+
|
|
143
|
+
await update($, sinStatusAtom, () => result.result);
|
|
144
|
+
};
|
|
145
|
+
|
|
84
146
|
// Keeps the machine watcher running as this session's child. The watcher writes the machine's state, holds its lock and
|
|
85
147
|
// Pushes its heartbeat, so its output is read only to keep the child's pipe drained. A watcher exits at once while another
|
|
86
148
|
// Session's holds the machine's lock, so each exit starts the watcher again after a wait, and the session that outlives the
|
|
@@ -91,14 +153,12 @@ const watchPulse = async ($: EngineInterface) => {
|
|
|
91
153
|
const cwd = await $.session.cwd();
|
|
92
154
|
if (!(await $.fs.exists(`${cwd}/${MACHINE_WATCHER_SCRIPT}`))) return;
|
|
93
155
|
|
|
94
|
-
|
|
95
|
-
for await (const _chunk of $.process.spawn({ argv: ["pnpm", "ai:machine:watch"], cwd })) {
|
|
156
|
+
for await (const _ of $.process.spawn({ argv: ["pnpm", "ai:machine:watch"], cwd })) {
|
|
96
157
|
// The watcher's output is discarded, since nothing in the session reads it
|
|
97
158
|
}
|
|
98
159
|
|
|
99
160
|
$.clock.after(MACHINE_WATCHER_RETRY_MILLISECONDS, () => {
|
|
100
|
-
|
|
101
|
-
watchPulse($);
|
|
161
|
+
runDetached(() => watchPulse($));
|
|
102
162
|
});
|
|
103
163
|
};
|
|
104
164
|
|
|
@@ -107,15 +167,21 @@ const closeCommission = async ($: EngineInterface) => {
|
|
|
107
167
|
await update($, isCommissionExpandedAtom, () => InitialState.isCommissionExpanded);
|
|
108
168
|
};
|
|
109
169
|
|
|
110
|
-
export const registerLifecycle = (on: On): void => {
|
|
170
|
+
export const registerLifecycle = (on: On, typeSafeKey: string): void => {
|
|
111
171
|
// The bare command flips a mod and an argument sets it, kept in the store so it holds for every later session
|
|
112
172
|
for (const name of ModNames)
|
|
113
173
|
on("command.run", { command: name }, async ($, e) => {
|
|
114
174
|
const argument = e.args.trim().toLowerCase();
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
175
|
+
const current = await read($, enabledModsAtom);
|
|
176
|
+
const isNext = argument ? argument === "on" : !current[name];
|
|
177
|
+
// Sins scores only with a TypeSafe key, so turning it on without one is refused and its value is left as it was
|
|
178
|
+
if (name === "sins" && isNext) {
|
|
179
|
+
const key = await readTypeSafeKey($, typeSafeKey);
|
|
180
|
+
if (!key)
|
|
181
|
+
return { text: "Sins needs a TypeSafe key: set the typesafe_key option, or TYPESAFE_API_KEY, first." };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const enabledMods = await update($, enabledModsAtom, (value) => ({ ...value, [name]: isNext }));
|
|
119
185
|
await $.store.set(enabledModsAtom.ref.key, enabledMods);
|
|
120
186
|
return { text: `${name} is ${enabledMods[name] ? "on" : "off"}.` };
|
|
121
187
|
});
|
|
@@ -128,7 +194,7 @@ export const registerLifecycle = (on: On): void => {
|
|
|
128
194
|
$.command.register({ argumentHint: "[on|off]", description: ModDescriptionMap[name], name }),
|
|
129
195
|
),
|
|
130
196
|
);
|
|
131
|
-
watchPulse($);
|
|
197
|
+
runDetached(() => watchPulse($));
|
|
132
198
|
return next(e);
|
|
133
199
|
});
|
|
134
200
|
|
|
@@ -137,6 +203,9 @@ export const registerLifecycle = (on: On): void => {
|
|
|
137
203
|
on("session.end", async ($, e, next) => {
|
|
138
204
|
if (e.reason === "clear" || e.reason === "resume") {
|
|
139
205
|
await update($, lastCacheRequestAtAtom, () => InitialState.lastCacheRequestAt);
|
|
206
|
+
await update($, sinHistoryAtom, () => InitialState.sinHistory);
|
|
207
|
+
await update($, sinSessionAtom, () => InitialState.sinSession);
|
|
208
|
+
await update($, sinStatusAtom, () => InitialState.sinStatus);
|
|
140
209
|
await update($, waypointsAtom, () => InitialState.waypoints);
|
|
141
210
|
await closeCommission($);
|
|
142
211
|
}
|
|
@@ -153,12 +222,15 @@ export const registerLifecycle = (on: On): void => {
|
|
|
153
222
|
await update($, lastPromptAtom, () => e.text);
|
|
154
223
|
const { tasks } = await read($, commissionAtom);
|
|
155
224
|
if (tasks.length > 0 && tasks.every(({ status }) => status === "completed")) await closeCommission($);
|
|
225
|
+
$.clock.after(0, () => {
|
|
226
|
+
runDetached(() => scoreSins($, e.text, typeSafeKey));
|
|
227
|
+
});
|
|
156
228
|
return next(e);
|
|
157
229
|
});
|
|
158
230
|
|
|
159
231
|
// While the veil is on, the model is told to write placeholders too, so a reply never holds a value to hide. While a
|
|
160
|
-
// Usage reserve holds and the resin mod is on, the model is told what the reserve
|
|
161
|
-
//
|
|
232
|
+
// Usage reserve holds and the resin mod is on, the model is told what the reserve asks until the window resets, which
|
|
233
|
+
// It may have done since the last measurement
|
|
162
234
|
on("prompt.compose", async ($, e, next) => {
|
|
163
235
|
const result = await next(e);
|
|
164
236
|
const { resin, veil } = await read($, enabledModsAtom);
|
|
@@ -200,25 +272,19 @@ export const registerLifecycle = (on: On): void => {
|
|
|
200
272
|
await armCacheWarning($);
|
|
201
273
|
if (e.reason === "answer" && (await read($, enabledModsAtom)).waypoints)
|
|
202
274
|
$.clock.after(0, () => {
|
|
203
|
-
|
|
204
|
-
suggestWaypoints($);
|
|
275
|
+
runDetached(() => suggestWaypoints($));
|
|
205
276
|
});
|
|
206
277
|
return result;
|
|
207
278
|
});
|
|
208
279
|
|
|
209
|
-
// The reserve is read off each measurement: it starts at the first window past
|
|
210
|
-
//
|
|
211
|
-
// Again as its maintenance tier is raised to the wind-down
|
|
280
|
+
// The reserve is read off each measurement: it starts at the first window past its line and lifts at the measurement a
|
|
281
|
+
// Window's reset raises, which finds none. The toast names the window once, as the reserve starts
|
|
212
282
|
on("session.measure", async ($, e, next) => {
|
|
213
283
|
const now = await $.clock.now();
|
|
214
284
|
const reserveWindow = getReserveWindow(e.rateLimits, now);
|
|
215
285
|
const previousReserveWindow = await read($, reserveWindowAtom);
|
|
216
286
|
await update($, reserveWindowAtom, () => reserveWindow ?? InitialState.reserveWindow);
|
|
217
|
-
if (
|
|
218
|
-
reserveWindow &&
|
|
219
|
-
(!previousReserveWindow.name || (reserveWindow.isWindDown && !previousReserveWindow.isWindDown)) &&
|
|
220
|
-
(await read($, enabledModsAtom)).resin
|
|
221
|
-
)
|
|
287
|
+
if (reserveWindow && !previousReserveWindow.name && (await read($, enabledModsAtom)).resin)
|
|
222
288
|
$.ui.toast(`${getReserveSummary(reserveWindow)}.`);
|
|
223
289
|
|
|
224
290
|
return next(e);
|
|
@@ -2,26 +2,18 @@ import type { UsageWindow } from "../../models/UsageWindow";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
FIVE_HOUR_MAINTENANCE_PERCENTAGE,
|
|
5
|
-
FIVE_HOUR_WIND_DOWN_PERCENTAGE,
|
|
6
5
|
FIVE_HOUR_WINDOW_MS,
|
|
7
6
|
WEEKLY_MAINTENANCE_PERCENTAGE,
|
|
8
|
-
WEEKLY_WIND_DOWN_PERCENTAGE,
|
|
9
7
|
WEEKLY_WINDOW_MS,
|
|
10
8
|
} from "../constants";
|
|
11
9
|
|
|
12
10
|
// The rate-limit windows a reserve is read off, five-hour before weekly, so the five-hour one is the one named when both
|
|
13
|
-
// Have passed
|
|
11
|
+
// Have passed their line
|
|
14
12
|
export const RateLimitKindUsageWindowMap: Record<string, UsageWindow> = {
|
|
15
13
|
five_hour: {
|
|
16
14
|
durationMs: FIVE_HOUR_WINDOW_MS,
|
|
17
15
|
maintenancePercentage: FIVE_HOUR_MAINTENANCE_PERCENTAGE,
|
|
18
16
|
name: "five-hour",
|
|
19
|
-
windDownPercentage: FIVE_HOUR_WIND_DOWN_PERCENTAGE,
|
|
20
|
-
},
|
|
21
|
-
seven_day: {
|
|
22
|
-
durationMs: WEEKLY_WINDOW_MS,
|
|
23
|
-
maintenancePercentage: WEEKLY_MAINTENANCE_PERCENTAGE,
|
|
24
|
-
name: "weekly",
|
|
25
|
-
windDownPercentage: WEEKLY_WIND_DOWN_PERCENTAGE,
|
|
26
17
|
},
|
|
18
|
+
seven_day: { durationMs: WEEKLY_WINDOW_MS, maintenancePercentage: WEEKLY_MAINTENANCE_PERCENTAGE, name: "weekly" },
|
|
27
19
|
};
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
// The reset time as the reader's own clock spells it, such as
|
|
1
|
+
// The reset time as the reader's own clock spells it, such as a short month, day, year and time
|
|
2
2
|
export const formatResetsAt = (resetsAt: string): string =>
|
|
3
3
|
Temporal.Instant.from(resetsAt).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" });
|
|
@@ -2,7 +2,7 @@ import type { ReserveWindow } from "../../../types";
|
|
|
2
2
|
|
|
3
3
|
import { formatResetsAt } from "./formatResetsAt";
|
|
4
4
|
|
|
5
|
-
// The sentence a reserve opens with, naming its
|
|
6
|
-
//
|
|
7
|
-
export const getReserveSummary = ({ isAheadOfPace,
|
|
8
|
-
|
|
5
|
+
// The sentence a reserve opens with, naming its window, the usage line that window has passed and its reset time, which
|
|
6
|
+
// The system section and the toast both say
|
|
7
|
+
export const getReserveSummary = ({ isAheadOfPace, name, percentage, resetsAt }: ReserveWindow): string =>
|
|
8
|
+
`Usage maintenance: the ${name} usage window has passed ${percentage}%${isAheadOfPace ? " ahead of an even pace" : ""} and resets at ${formatResetsAt(resetsAt)}`;
|
|
@@ -5,28 +5,23 @@ import type { ReserveWindow } from "../../../types";
|
|
|
5
5
|
import { PACE_FLOOR_PERCENTAGE } from "../constants";
|
|
6
6
|
import { RateLimitKindUsageWindowMap } from "./RateLimitKindUsageWindowMap";
|
|
7
7
|
|
|
8
|
-
// Each window a reserve is read off, five-hour before weekly, so the five-hour one is the one named when both
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// Each window a reserve is read off, five-hour before weekly, so the five-hour one is the one named when both qualify. A
|
|
9
|
+
// Window is read once past its maintenance line, or once half of its allowance is spent ahead of an even pace, the share
|
|
10
|
+
// Of its length that has elapsed. A window the engine gives no reset time for is skipped, since the reserve is named by
|
|
11
|
+
// That time and lifts at it
|
|
12
12
|
export const getReserveWindow = (rateLimits: SessionRateLimit[], now: number): ReserveWindow | undefined => {
|
|
13
13
|
const reserveWindows: ReserveWindow[] = [];
|
|
14
|
-
for (const [kind, { durationMs, maintenancePercentage, name
|
|
15
|
-
RateLimitKindUsageWindowMap,
|
|
16
|
-
)) {
|
|
14
|
+
for (const [kind, { durationMs, maintenancePercentage, name }] of Object.entries(RateLimitKindUsageWindowMap)) {
|
|
17
15
|
const rateLimit = rateLimits.find((limit) => limit.kind === kind);
|
|
18
16
|
if (rateLimit?.resetsAt === undefined) continue;
|
|
19
|
-
const isWindDown = rateLimit.percentUsed >= windDownPercentage;
|
|
20
17
|
const isPastMaintenance = rateLimit.percentUsed >= maintenancePercentage;
|
|
21
18
|
const elapsedPercentage = 100 * (1 - (Date.parse(rateLimit.resetsAt) - now) / durationMs);
|
|
22
19
|
const isAheadOfPace =
|
|
23
20
|
!isPastMaintenance && rateLimit.percentUsed >= PACE_FLOOR_PERCENTAGE && rateLimit.percentUsed > elapsedPercentage;
|
|
24
21
|
if (!isPastMaintenance && !isAheadOfPace) continue;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
else if (isPastMaintenance) percentage = maintenancePercentage;
|
|
28
|
-
reserveWindows.push({ isAheadOfPace, isWindDown, name, percentage, resetsAt: rateLimit.resetsAt });
|
|
22
|
+
const percentage = isPastMaintenance ? maintenancePercentage : PACE_FLOOR_PERCENTAGE;
|
|
23
|
+
reserveWindows.push({ isAheadOfPace, name, percentage, resetsAt: rateLimit.resetsAt });
|
|
29
24
|
}
|
|
30
25
|
|
|
31
|
-
return reserveWindows
|
|
26
|
+
return reserveWindows[0];
|
|
32
27
|
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ReserveWindow } from "../../../types";
|
|
2
2
|
|
|
3
|
-
import { MAINTENANCE_INSTRUCTION
|
|
3
|
+
import { MAINTENANCE_INSTRUCTION } from "../constants";
|
|
4
4
|
import { getReserveSummary } from "./getReserveSummary";
|
|
5
5
|
|
|
6
|
-
// The system section the reserve adds:
|
|
7
|
-
//
|
|
6
|
+
// The system section the reserve adds: the summary is the only part that varies, with the window it names and the line
|
|
7
|
+
// That window has passed, so the text holds still while neither changes
|
|
8
8
|
export const reserveText = (reserveWindow: ReserveWindow): string =>
|
|
9
|
-
`${getReserveSummary(reserveWindow)}. ${
|
|
9
|
+
`${getReserveSummary(reserveWindow)}. ${MAINTENANCE_INSTRUCTION}`;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { SinName } from "../../models/SinName";
|
|
2
|
+
|
|
3
|
+
// One fixed slot of the dataviz reference palette per sin, so a sin keeps its colour as its score moves
|
|
4
|
+
export const SinColorMap: Record<SinName, { dark: string; light: string }> = {
|
|
5
|
+
[SinName.Envy]: { dark: "#c98500", light: "#eda100" },
|
|
6
|
+
[SinName.Gluttony]: { dark: "#008300", light: "#008300" },
|
|
7
|
+
[SinName.Greed]: { dark: "#d95926", light: "#eb6834" },
|
|
8
|
+
[SinName.Lust]: { dark: "#d55181", light: "#e87ba4" },
|
|
9
|
+
[SinName.Pride]: { dark: "#3987e5", light: "#2a78d6" },
|
|
10
|
+
[SinName.Sloth]: { dark: "#9085e9", light: "#6250d6" },
|
|
11
|
+
[SinName.Wrath]: { dark: "#199e70", light: "#1baf7a" },
|
|
12
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SinStatus } from "../../../types";
|
|
2
|
+
|
|
3
|
+
import { SinStatusKind } from "../../models/SinStatusKind";
|
|
4
|
+
|
|
5
|
+
// Why the band and the pane say so instead of fresh scores, for the statuses that have a reason to say
|
|
6
|
+
export const SinStatusTextMap: Partial<Record<SinStatus, string>> = {
|
|
7
|
+
[SinStatusKind.Failed]: "Last prompt not scored",
|
|
8
|
+
[SinStatusKind.NodeTooOld]: "Needs Node 22.13 or later on 22, or 23.4 or later on 23",
|
|
9
|
+
[SinStatusKind.NoKey]: "Needs a TypeSafe key",
|
|
10
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SinsResult } from "../../models/SinsResult";
|
|
2
|
+
import type { SinStatusKind } from "../../models/SinStatusKind";
|
|
3
|
+
|
|
4
|
+
import { SIN_NAMES } from "../../models/SinName";
|
|
5
|
+
import { SIN_HISTORY_LENGTH, SIN_TOP_LEVEL } from "./constants";
|
|
6
|
+
|
|
7
|
+
// Each sin's evidence gains this prompt's score as a share of the top level, clamped to [0, 1], and the oldest values fall
|
|
8
|
+
// Away past the history's length
|
|
9
|
+
export const appendSinEvidence = (
|
|
10
|
+
history: Record<string, number[]>,
|
|
11
|
+
answers: Extract<SinsResult, { result: SinStatusKind.Answered }>["answers"],
|
|
12
|
+
): Record<string, number[]> =>
|
|
13
|
+
Object.fromEntries(
|
|
14
|
+
SIN_NAMES.map((name) => {
|
|
15
|
+
const evidence = Math.min(1, Math.max(0, (answers[name]?.score ?? 0) / SIN_TOP_LEVEL));
|
|
16
|
+
return [name, [...(history[name] ?? []), evidence].slice(-SIN_HISTORY_LENGTH)];
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The five levels every question grades on, from none to extreme, so the sins are comparable with one another
|
|
2
|
+
export const SIN_CRITERIA = [
|
|
3
|
+
"Absent: the prompt shows none of it",
|
|
4
|
+
"Slight: a trace of it in passing",
|
|
5
|
+
"Moderate: it shapes part of the prompt",
|
|
6
|
+
"Strong: it shapes most of the prompt",
|
|
7
|
+
"Extreme: it is the whole prompt",
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
// The top level the score reads, so a score divides by it to lie between none and extreme
|
|
11
|
+
export const SIN_TOP_LEVEL: number = SIN_CRITERIA.length - 1;
|
|
12
|
+
|
|
13
|
+
// The half-life in prompts: a sin's score moves about halfway toward a new pattern within this many prompts
|
|
14
|
+
export const SIN_HALF_LIFE_PROMPTS = 30;
|
|
15
|
+
|
|
16
|
+
// Applied to the old counts on each prompt, so each count halves every half-life
|
|
17
|
+
export const SIN_DISCOUNT: number = 2 ** (-1 / SIN_HALF_LIFE_PROMPTS);
|
|
18
|
+
|
|
19
|
+
// One pseudo-count of each outcome, so a sin with no evidence reads as an even half
|
|
20
|
+
export const SIN_PRIOR = 1;
|
|
21
|
+
|
|
22
|
+
// The evidence kept per sin: past this the discount has taken the rest, under a tenth of a percent
|
|
23
|
+
export const SIN_HISTORY_LENGTH = 300;
|
|
24
|
+
|
|
25
|
+
// The bar's width in cells, the solid run to the score and the tinted run to the score plus its spread
|
|
26
|
+
export const SIN_BAR_WIDTH = 24;
|
|
27
|
+
|
|
28
|
+
export const SINS_PANE_ID = "sins";
|
|
29
|
+
|
|
30
|
+
// The node script's whole run, its one request included; a run past this is killed and reads as failed
|
|
31
|
+
export const SINS_TIMEOUT_MS = 60_000;
|
|
32
|
+
|
|
33
|
+
export const SINS_API_URL = "https://api.typesafe.ai/v1/systemone";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SinFigures } from "../../models/SinFigures";
|
|
2
|
+
|
|
3
|
+
// The solid run reaches the score, and the tinted run the score plus its spread, both in cells of the bar's width
|
|
4
|
+
export const getBarCells = ({ score, spread }: SinFigures, width: number): { solid: number; tinted: number } => {
|
|
5
|
+
const solid = Math.round(score * width);
|
|
6
|
+
const reach = Math.round(Math.min(1, score + spread) * width);
|
|
7
|
+
return { solid, tinted: Math.max(0, reach - solid) };
|
|
8
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SinPosterior } from "../../models/SinPosterior";
|
|
2
|
+
|
|
3
|
+
import { SIN_DISCOUNT, SIN_PRIOR } from "./constants";
|
|
4
|
+
|
|
5
|
+
// Each evidence value is replayed in order, the counts discounted before it is added, so the trail is the posterior
|
|
6
|
+
// After each prompt and its last entry is the posterior now
|
|
7
|
+
export const getPosteriorTrail = (evidences: number[]): SinPosterior[] => {
|
|
8
|
+
let posterior: SinPosterior = { alpha: SIN_PRIOR, beta: SIN_PRIOR };
|
|
9
|
+
return evidences.map((evidence) => {
|
|
10
|
+
posterior = {
|
|
11
|
+
alpha: SIN_DISCOUNT * posterior.alpha + evidence,
|
|
12
|
+
beta: SIN_DISCOUNT * posterior.beta + (1 - evidence),
|
|
13
|
+
};
|
|
14
|
+
return posterior;
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// The posterior now is the trail's last entry, and a sin with no evidence reads as the prior: an even half with the spread
|
|
19
|
+
// Of one count of each outcome
|
|
20
|
+
export const getPosterior = (trail: SinPosterior[]): SinPosterior =>
|
|
21
|
+
trail.at(-1) ?? { alpha: SIN_PRIOR, beta: SIN_PRIOR };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SinFigures } from "../../models/SinFigures";
|
|
2
|
+
import type { SinPosterior } from "../../models/SinPosterior";
|
|
3
|
+
|
|
4
|
+
// The score is the posterior mean and the spread its standard deviation, from the Beta's own variance
|
|
5
|
+
export const getScoreAndSpread = ({ alpha, beta }: SinPosterior): SinFigures => {
|
|
6
|
+
const total = alpha + beta;
|
|
7
|
+
return { score: alpha / total, spread: Math.sqrt((alpha * beta) / (total * total * (total + 1))) };
|
|
8
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { SIN_NAMES, SinName } from "../../models/SinName";
|
|
2
|
+
import { SIN_CRITERIA } from "./constants";
|
|
3
|
+
|
|
4
|
+
// The question each sin asks Jev about a prompt, held beside the one call that asks them all
|
|
5
|
+
const SinQuestionMap = {
|
|
6
|
+
[SinName.Envy]:
|
|
7
|
+
'How far does the prompt measure against another tool, person or project, as in "the other tool", "like theirs" or "why doesn\'t ours do what X does"?',
|
|
8
|
+
[SinName.Gluttony]: "How heavy is the prompt, as in a large pasted share of text or a whole file or log pasted in?",
|
|
9
|
+
[SinName.Greed]:
|
|
10
|
+
'How far does the prompt ask for more than one request holds, as in "and also", "while you\'re at it", "everything" or several tasks at once?',
|
|
11
|
+
[SinName.Lust]:
|
|
12
|
+
'How far does the prompt crave the new over the finished, as in "rewrite", "from scratch", "redesign" or "a new approach"?',
|
|
13
|
+
[SinName.Pride]:
|
|
14
|
+
'How far does the prompt assert certainty and wave off checks, as in "obviously", "definitely", "no need to test" or "trust me"?',
|
|
15
|
+
[SinName.Sloth]:
|
|
16
|
+
'How far is effort handed off without a steer, as in "idk", "whatever", "you decide", "figure it out" or a one-word prompt?',
|
|
17
|
+
[SinName.Wrath]:
|
|
18
|
+
'How heated is the prompt\'s register, as in runs of punctuation, words in capitals, "again", "still broken" or "why won\'t"?',
|
|
19
|
+
} as const satisfies Record<SinName, string>;
|
|
20
|
+
|
|
21
|
+
// The seven `score` questions one call answers, keyed by sin name, each graded on the same five levels
|
|
22
|
+
export const getSinQuestions = (): Record<
|
|
23
|
+
string,
|
|
24
|
+
{ criteria: typeof SIN_CRITERIA; instructions: string; type: "score" }
|
|
25
|
+
> =>
|
|
26
|
+
Object.fromEntries(
|
|
27
|
+
SIN_NAMES.map((name) => [name, { criteria: SIN_CRITERIA, instructions: SinQuestionMap[name], type: "score" }]),
|
|
28
|
+
);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SinScore } from "../../models/SinScore";
|
|
2
|
+
|
|
3
|
+
import { SIN_NAMES } from "../../models/SinName";
|
|
4
|
+
import { getPosterior, getPosteriorTrail } from "./getPosteriorTrail";
|
|
5
|
+
import { getScoreAndSpread } from "./getScoreAndSpread";
|
|
6
|
+
import { getTrend } from "./getTrend";
|
|
7
|
+
|
|
8
|
+
// The sins the person can read, in the pane's order: one with no evidence is left out rather than shown as a zero
|
|
9
|
+
export const getSinScores = (history: Record<string, number[]>): SinScore[] =>
|
|
10
|
+
SIN_NAMES.flatMap((name) => {
|
|
11
|
+
const evidences = history[name] ?? [];
|
|
12
|
+
if (evidences.length === 0) return [];
|
|
13
|
+
|
|
14
|
+
const trail = getPosteriorTrail(evidences);
|
|
15
|
+
const figures = getScoreAndSpread(getPosterior(trail));
|
|
16
|
+
return [{ name, ...figures, trend: getTrend(trail) }];
|
|
17
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SinPosterior } from "../../models/SinPosterior";
|
|
2
|
+
|
|
3
|
+
import { SIN_HALF_LIFE_PROMPTS } from "./constants";
|
|
4
|
+
|
|
5
|
+
const TREND_GLYPHS = "▁▂▃▄▅▆▇█";
|
|
6
|
+
|
|
7
|
+
// The score after each of the last half-life's prompts, from the trail the score reads, so it is the score's own curve
|
|
8
|
+
export const getTrend = (trail: SinPosterior[]): number[] =>
|
|
9
|
+
trail.slice(-SIN_HALF_LIFE_PROMPTS).map(({ alpha, beta }) => alpha / (alpha + beta));
|
|
10
|
+
|
|
11
|
+
// One glyph per point, its height the point's share of the tallest glyph
|
|
12
|
+
export const getTrendGlyphs = (trend: number[]): string =>
|
|
13
|
+
trend.map((score) => TREND_GLYPHS.charAt(Math.round(score * (TREND_GLYPHS.length - 1)))).join("");
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { SinsResult } from "../../models/SinsResult";
|
|
2
|
+
|
|
3
|
+
// The node script writes one JSON result when it exits cleanly, so a run that exited otherwise never reaches this
|
|
4
|
+
// oxlint-disable-next-line no-restricted-properties -- packages/genshin-mods/package.json has no @esposter/shared dependency, so jsonDateParse is not importable here, and SinsResult (src/models/SinsResult.ts) holds no Date a revival would produce
|
|
5
|
+
export const parseSinsResult = (stdout: string): SinsResult => JSON.parse(stdout) as SinsResult;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// The key sins scores with: the plugin's own option when it is set, else the variable. A blank option is no key, so the
|
|
2
|
+
// Variable answers, and `||` is what makes it so, since `??` would keep a blank option over a variable that holds one
|
|
3
|
+
export const pickTypeSafeKey = (typeSafeKey: string, variableKey: string | undefined): string =>
|
|
4
|
+
typeSafeKey || variableKey || "";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { EngineInterface } from "claude-code";
|
|
2
|
+
|
|
3
|
+
// The engine's theme row names a light theme with "light" in it, and any other theme reads as dark
|
|
4
|
+
// oxlint-disable-next-line func-style -- A declaration, not an arrow: the hooks module's bundle follows an imported helper that takes `$` only into a function declaration
|
|
5
|
+
export async function readIsLightTheme($: EngineInterface): Promise<boolean> {
|
|
6
|
+
const theme = (await $.config.list()).find(({ key }) => key === "theme");
|
|
7
|
+
return String(theme?.value ?? "").includes("light");
|
|
8
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { EngineInterface } from "claude-code";
|
|
2
|
+
|
|
3
|
+
import { atom, read } from "claude-code";
|
|
4
|
+
|
|
5
|
+
import type { SinStatus } from "../../../types";
|
|
6
|
+
|
|
7
|
+
import { SinStatusKind } from "../../models/SinStatusKind";
|
|
8
|
+
import { InitialState } from "../InitialState";
|
|
9
|
+
import { readTypeSafeKey } from "./readTypeSafeKey";
|
|
10
|
+
|
|
11
|
+
const sinStatusAtom = atom({ key: "sinStatus", plugin: "genshin-mods" } as const, InitialState.sinStatus);
|
|
12
|
+
|
|
13
|
+
// The status as the band and the pane draw it: a key removed since the last prompt is said so at each draw, before the next
|
|
14
|
+
// Prompt would say it, and the stored status stands otherwise
|
|
15
|
+
// oxlint-disable-next-line func-style -- A declaration, not an arrow: the hooks module's bundle follows an imported helper that takes `$` into a function declaration
|
|
16
|
+
export async function readSinStatus($: EngineInterface, typeSafeKey: string): Promise<SinStatus> {
|
|
17
|
+
if (!(await readTypeSafeKey($, typeSafeKey))) return SinStatusKind.NoKey;
|
|
18
|
+
|
|
19
|
+
return read($, sinStatusAtom);
|
|
20
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { EngineInterface } from "claude-code";
|
|
2
|
+
|
|
3
|
+
import { pickTypeSafeKey } from "./pickTypeSafeKey";
|
|
4
|
+
|
|
5
|
+
// The key sins scores with: the plugin's option, else the TYPESAFE_API_KEY variable, the two places Genshin-persona reads
|
|
6
|
+
// Its key from, so one key set the same way serves both plugins
|
|
7
|
+
// oxlint-disable-next-line func-style -- A declaration, not an arrow: the hooks module's bundle follows an imported helper that takes `$` into a function declaration
|
|
8
|
+
export async function readTypeSafeKey($: EngineInterface, typeSafeKey: string): Promise<string> {
|
|
9
|
+
return pickTypeSafeKey(typeSafeKey, await $.env.get("TYPESAFE_API_KEY"));
|
|
10
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { On } from "claude-code";
|
|
2
|
+
|
|
3
|
+
import { atom, read } from "claude-code";
|
|
4
|
+
|
|
5
|
+
import { InitialState } from "../InitialState";
|
|
6
|
+
import { SIN_BAR_WIDTH, SINS_PANE_ID } from "./constants";
|
|
7
|
+
import { getBarCells } from "./getBarCells";
|
|
8
|
+
import { getSinScores } from "./getSinScores";
|
|
9
|
+
import { getTrendGlyphs } from "./getTrend";
|
|
10
|
+
import { readIsLightTheme } from "./readIsLightTheme";
|
|
11
|
+
import { readSinStatus } from "./readSinStatus";
|
|
12
|
+
import { SinColorMap } from "./SinColorMap";
|
|
13
|
+
import { SinStatusTextMap } from "./SinStatusTextMap";
|
|
14
|
+
|
|
15
|
+
const enabledModsAtom = atom({ key: "enabledMods", plugin: "genshin-mods" } as const, InitialState.enabledMods);
|
|
16
|
+
const sinHistoryAtom = atom({ key: "sinHistory", plugin: "genshin-mods" } as const, InitialState.sinHistory);
|
|
17
|
+
|
|
18
|
+
// The seven sins in full: one bar per measured sin in the same order every time, its spread as the tinted tail, and its
|
|
19
|
+
// Last thirty prompts as a trend. It is drawn from the state alone, so a reload redraws it as it was, and it says so
|
|
20
|
+
// Rather than drawing stale bars once the switch is off
|
|
21
|
+
export const registerSins = (on: On, typeSafeKey: string): void => {
|
|
22
|
+
on("ui.render", { component: "Pane", requestId: SINS_PANE_ID }, async ($, e) => {
|
|
23
|
+
const { Box, Text } = $.ui.resolve(e);
|
|
24
|
+
if (!(await read($, enabledModsAtom)).sins)
|
|
25
|
+
return Box({
|
|
26
|
+
children: [Text({ children: "Sins is off: turn it on with /sins on.", dimColor: true })],
|
|
27
|
+
flexDirection: "column",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const scores = getSinScores(await read($, sinHistoryAtom));
|
|
31
|
+
const isLight = await readIsLightTheme($);
|
|
32
|
+
const statusText = SinStatusTextMap[await readSinStatus($, typeSafeKey)];
|
|
33
|
+
return Box({
|
|
34
|
+
children: [
|
|
35
|
+
Text({ bold: true, children: "Seven sins" }),
|
|
36
|
+
...(scores.length === 0 ? [Text({ children: "No prompt scored yet.", dimColor: true })] : []),
|
|
37
|
+
...scores.map(({ name, score, spread, trend }) => {
|
|
38
|
+
const color = SinColorMap[name][isLight ? "light" : "dark"];
|
|
39
|
+
const { solid, tinted } = getBarCells({ score, spread }, SIN_BAR_WIDTH);
|
|
40
|
+
const bar = "█".repeat(solid) + "▒".repeat(tinted) + " ".repeat(SIN_BAR_WIDTH - solid - tinted);
|
|
41
|
+
return Box({
|
|
42
|
+
children: [
|
|
43
|
+
Text({ children: name.padEnd(9), color }),
|
|
44
|
+
Text({ children: bar, color }),
|
|
45
|
+
Text({ children: `${Math.round(score * 100)}% ±${Math.round(spread * 100)}`.padEnd(12) }),
|
|
46
|
+
Text({ children: getTrendGlyphs(trend), color }),
|
|
47
|
+
],
|
|
48
|
+
flexDirection: "row",
|
|
49
|
+
});
|
|
50
|
+
}),
|
|
51
|
+
...(statusText ? [Text({ children: statusText, dimColor: true })] : []),
|
|
52
|
+
],
|
|
53
|
+
flexDirection: "column",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { SinStatusKind } from "../../models/SinStatusKind";
|
|
2
|
+
|
|
3
|
+
// The plugin's node script, run as `node --input-type=module -e` with its request on stdin: the one network call and the
|
|
4
|
+
// Evidence log run here, since the hooks module has no Node. It is a string because a plugin reaches none of its own
|
|
5
|
+
// Files from inside the module, and it writes exactly one JSON result, so a run that exits otherwise reads as failed
|
|
6
|
+
// oxlint-disable-next-line typescript/no-inferrable-types -- isolatedDeclarations asks for the type of an exported template that interpolates, and this rule would refuse that annotation
|
|
7
|
+
export const SINS_SCRIPT: string = `
|
|
8
|
+
import { mkdirSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
|
|
11
|
+
let input = "";
|
|
12
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
13
|
+
const { apiUrl, key, questions, session, state, storePath } = JSON.parse(input);
|
|
14
|
+
const finish = (result) => process.stdout.write(JSON.stringify(result));
|
|
15
|
+
const sqlite = process.getBuiltinModule?.("node:sqlite");
|
|
16
|
+
|
|
17
|
+
if (!key) finish({ result: "${SinStatusKind.NoKey}" });
|
|
18
|
+
else if (!sqlite) finish({ result: "${SinStatusKind.NodeTooOld}" });
|
|
19
|
+
else {
|
|
20
|
+
const response = await fetch(apiUrl, {
|
|
21
|
+
body: JSON.stringify({ model: "jev-latest", questions, state }),
|
|
22
|
+
headers: { Authorization: "Bearer " + key, "Content-Type": "application/json" },
|
|
23
|
+
method: "POST",
|
|
24
|
+
});
|
|
25
|
+
const answers = response.ok ? (await response.json()).answers || {} : {};
|
|
26
|
+
const names = Object.keys(questions);
|
|
27
|
+
const isAnswered = names.every(
|
|
28
|
+
(name) => typeof answers[name]?.score === "number" && typeof answers[name]?.confidence === "number",
|
|
29
|
+
);
|
|
30
|
+
if (!isAnswered) finish({ reason: response.ok ? "unanswered" : "HTTP " + response.status, result: "${SinStatusKind.Failed}" });
|
|
31
|
+
else {
|
|
32
|
+
mkdirSync(dirname(storePath), { recursive: true });
|
|
33
|
+
const database = new sqlite.DatabaseSync(storePath);
|
|
34
|
+
try {
|
|
35
|
+
database.exec("PRAGMA busy_timeout = 5000");
|
|
36
|
+
database.exec(
|
|
37
|
+
"CREATE TABLE IF NOT EXISTS evidence (id INTEGER PRIMARY KEY, session INTEGER NOT NULL, answers TEXT NOT NULL, recorded_at INTEGER NOT NULL)",
|
|
38
|
+
);
|
|
39
|
+
const picked = Object.fromEntries(names.map((name) => [name, { confidence: answers[name].confidence, score: answers[name].score }]));
|
|
40
|
+
database.prepare("INSERT INTO evidence (session, answers, recorded_at) VALUES (?, ?, ?)").run(session, JSON.stringify(picked), Date.now());
|
|
41
|
+
finish({ answers: picked, result: "${SinStatusKind.Answered}" });
|
|
42
|
+
} finally {
|
|
43
|
+
database.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { EngineInterface } from "claude-code";
|
|
2
2
|
|
|
3
|
+
import { readHome } from "../readHome";
|
|
3
4
|
import { getRecordsPath } from "./getRecordsPath";
|
|
4
5
|
|
|
5
6
|
// The records file under the person's home folder, the home read from the engine's environment lookup
|
|
6
7
|
// oxlint-disable-next-line func-style -- A declaration, not an arrow: the hooks module's bundle follows an imported helper that takes `$` only into a function declaration
|
|
7
8
|
export async function resolveRecordsPath($: EngineInterface): Promise<string> {
|
|
8
|
-
return getRecordsPath(
|
|
9
|
+
return getRecordsPath(await readHome($));
|
|
9
10
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -8,7 +8,6 @@ export interface Commission {
|
|
|
8
8
|
|
|
9
9
|
export interface CommissionTask {
|
|
10
10
|
id: string;
|
|
11
|
-
// oxlint-disable-next-line literal-union/no-string-literal-union -- The engine's own task statuses, as its task tools spell them
|
|
12
11
|
status: "completed" | "in_progress" | "pending";
|
|
13
12
|
subject: string;
|
|
14
13
|
}
|
|
@@ -18,22 +17,25 @@ export interface EnabledMods {
|
|
|
18
17
|
commission: boolean;
|
|
19
18
|
pulse: boolean;
|
|
20
19
|
resin: boolean;
|
|
20
|
+
sins: boolean;
|
|
21
21
|
veil: boolean;
|
|
22
22
|
ward: boolean;
|
|
23
23
|
waypoints: boolean;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
// The rate-limit window past a usage line, named as its section spells it, the line it has passed, whether
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// The rate-limit window past a usage line, named as its section spells it, the line it has passed, whether the reserve
|
|
27
|
+
// Was entered by spending ahead of an even pace rather than by the window's maintenance line, and when it resets; `""`
|
|
28
|
+
// In the name is the absent reserve
|
|
29
29
|
export interface ReserveWindow {
|
|
30
30
|
isAheadOfPace: boolean;
|
|
31
|
-
isWindDown: boolean;
|
|
32
31
|
name: string;
|
|
33
32
|
percentage: number;
|
|
34
33
|
resetsAt: string;
|
|
35
34
|
}
|
|
36
35
|
|
|
36
|
+
// What the sins mod last found: its scores when the call answered, or why it could not score the last prompt
|
|
37
|
+
export type SinStatus = "answered" | "failed" | "no-key" | "node-too-old" | "unchecked";
|
|
38
|
+
|
|
37
39
|
export interface WardRecord {
|
|
38
40
|
editedAt: number;
|
|
39
41
|
sessionId: string;
|
|
@@ -51,6 +53,10 @@ declare module "claude-code" {
|
|
|
51
53
|
lookupStreak: number;
|
|
52
54
|
now: number;
|
|
53
55
|
reserveWindow: ReserveWindow;
|
|
56
|
+
// Keyed by sin name, the last evidence values of each sin in order
|
|
57
|
+
sinHistory: Record<string, number[]>;
|
|
58
|
+
sinSession: number;
|
|
59
|
+
sinStatus: SinStatus;
|
|
54
60
|
waypoints: string[];
|
|
55
61
|
};
|
|
56
62
|
}
|