@rol1n/pi-kit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -2
- package/extensions/ask-u-questions/README.md +32 -0
- package/extensions/ask-u-questions/answers.ts +53 -0
- package/extensions/ask-u-questions/dialog-ui.ts +57 -0
- package/extensions/ask-u-questions/index.ts +88 -0
- package/extensions/ask-u-questions/multi-choice.ts +260 -0
- package/extensions/ask-u-questions/results.ts +83 -0
- package/extensions/ask-u-questions/schema.ts +17 -0
- package/extensions/ask-u-questions/single-choice.ts +182 -0
- package/extensions/ask-u-questions/tool-renderers.ts +69 -0
- package/extensions/ask-u-questions/types.ts +45 -0
- package/extensions/ask-u-questions/ui-lock.ts +24 -0
- package/extensions/continue-signal/README.md +28 -0
- package/extensions/continue-signal/index.ts +51 -0
- package/extensions/continue-signal/renderer.ts +21 -0
- package/extensions/custom-providers/README.md +28 -0
- package/extensions/custom-providers/asr/handlers/dashscope.ts +154 -0
- package/extensions/custom-providers/asr/index.ts +37 -0
- package/extensions/custom-providers/asr/models.ts +18 -0
- package/extensions/custom-providers/asr/types.ts +20 -0
- package/extensions/custom-providers/chat/index.ts +33 -0
- package/extensions/custom-providers/chat/magpie.ts +258 -0
- package/extensions/custom-providers/chat/models.ts +111 -0
- package/extensions/custom-providers/classifier/index.ts +6 -0
- package/extensions/custom-providers/classifier/models.ts +1 -0
- package/extensions/custom-providers/image/handlers/dashscope.ts +240 -0
- package/extensions/custom-providers/image/handlers/openrouter.ts +185 -0
- package/extensions/custom-providers/image/index.ts +96 -0
- package/extensions/custom-providers/image/models.ts +71 -0
- package/extensions/custom-providers/image/types.ts +22 -0
- package/extensions/custom-providers/index.ts +50 -0
- package/extensions/custom-providers/types.ts +17 -0
- package/extensions/image-generate/README.md +29 -0
- package/extensions/image-generate/files.ts +49 -0
- package/extensions/image-generate/index.ts +200 -0
- package/extensions/image-generate/model-selector.ts +150 -0
- package/extensions/image-generate/models.ts +19 -0
- package/extensions/memory-log/README.md +54 -0
- package/extensions/memory-log/agent/consolidator/prompt.ts +47 -0
- package/extensions/memory-log/agent/consolidator/tools.ts +160 -0
- package/extensions/memory-log/agent/cost.ts +32 -0
- package/extensions/memory-log/agent/index.ts +42 -0
- package/extensions/memory-log/agent/observer/prompt.ts +73 -0
- package/extensions/memory-log/agent/observer/tool.ts +73 -0
- package/extensions/memory-log/commands/compact.ts +35 -0
- package/extensions/memory-log/commands/consolidate.ts +47 -0
- package/extensions/memory-log/commands/status.ts +50 -0
- package/extensions/memory-log/config.ts +253 -0
- package/extensions/memory-log/debug-log.ts +76 -0
- package/extensions/memory-log/hooks/compaction-hook.ts +161 -0
- package/extensions/memory-log/hooks/compaction-trigger.ts +160 -0
- package/extensions/memory-log/hooks/consolidator-trigger.ts +143 -0
- package/extensions/memory-log/hooks/observer-trigger.ts +176 -0
- package/extensions/memory-log/ids.ts +78 -0
- package/extensions/memory-log/index.ts +149 -0
- package/extensions/memory-log/ledger/fold.ts +77 -0
- package/extensions/memory-log/ledger/index.ts +7 -0
- package/extensions/memory-log/ledger/pool.ts +38 -0
- package/extensions/memory-log/ledger/progress.ts +181 -0
- package/extensions/memory-log/ledger/projection.ts +115 -0
- package/extensions/memory-log/ledger/render.ts +38 -0
- package/extensions/memory-log/ledger/serialize.ts +161 -0
- package/extensions/memory-log/ledger/types.ts +201 -0
- package/extensions/memory-log/memory/index-render.ts +45 -0
- package/extensions/memory-log/memory/paths.ts +120 -0
- package/extensions/memory-log/memory/session.ts +64 -0
- package/extensions/memory-log/runtime.ts +127 -0
- package/extensions/memory-log/spawn/launch.ts +133 -0
- package/extensions/memory-log/spawn/runs.ts +83 -0
- package/extensions/memory-log/tokens.ts +66 -0
- package/extensions/memory-log/ui/status-controller.ts +225 -0
- package/extensions/memory-log/ui/timeline.ts +103 -0
- package/extensions/model-vision/README.md +28 -0
- package/extensions/multi-harness/README.md +63 -0
- package/extensions/multi-harness/adapters.mjs +72 -0
- package/extensions/multi-harness/harness-done.mjs +14 -0
- package/extensions/multi-harness/index.ts +375 -0
- package/extensions/multi-harness/ipc.mjs +37 -0
- package/extensions/multi-harness/opencode-plugin.mjs +99 -0
- package/extensions/multi-harness/process-runner.mjs +115 -0
- package/extensions/multi-harness/turn-hook.mjs +29 -0
- package/extensions/multi-harness/worker-bridge.ts +52 -0
- package/extensions/oai-subquota/README.md +30 -0
- package/extensions/oai-subquota/api.ts +27 -0
- package/extensions/oai-subquota/auth.ts +34 -0
- package/extensions/oai-subquota/cache.ts +30 -0
- package/extensions/oai-subquota/index.ts +276 -0
- package/extensions/oai-subquota/snapshot.ts +167 -0
- package/extensions/oai-subquota/types.ts +40 -0
- package/extensions/oai-subquota/ui.ts +183 -0
- package/extensions/prompt-snipaste/README.md +33 -0
- package/extensions/prompt-snipaste/index.ts +143 -0
- package/extensions/prompt-snipaste/menu.ts +159 -0
- package/extensions/prompt-snipaste/renderers.ts +88 -0
- package/extensions/prompt-snipaste/snippets.ts +203 -0
- package/extensions/prompt-snipaste/types.ts +19 -0
- package/extensions/screenshot/README.md +25 -0
- package/extensions/srp-theme/README.md +24 -0
- package/extensions/srp-theme/footer.ts +194 -0
- package/extensions/srp-theme/index.ts +172 -0
- package/extensions/srp-theme/tps-meter.ts +442 -0
- package/extensions/subagent/README.md +35 -0
- package/extensions/subagent/bridge.ts +171 -0
- package/extensions/subagent/index.ts +590 -0
- package/extensions/subagent/mux.ts +621 -0
- package/extensions/subagent/session.ts +166 -0
- package/extensions/subagent/skills/subagent/SKILL.md +70 -0
- package/extensions/subagent/ui.ts +100 -0
- package/extensions/tui-asr/README.md +28 -0
- package/extensions/tui-asr/audio.ts +40 -0
- package/extensions/tui-asr/clipboard.ts +30 -0
- package/extensions/tui-asr/dictation.ts +205 -0
- package/extensions/tui-asr/index.ts +181 -0
- package/extensions/tui-asr/input.ts +58 -0
- package/extensions/tui-asr/model-selector.ts +163 -0
- package/extensions/tui-asr/providers.ts +25 -0
- package/extensions/tui-asr/status.ts +79 -0
- package/extensions/web-reach/README.md +37 -0
- package/extensions/web-reach/html.ts +83 -0
- package/extensions/web-reach/index.ts +82 -0
- package/extensions/web-reach/page.ts +132 -0
- package/extensions/web-reach/pdf.ts +42 -0
- package/extensions/web-reach/reader.ts +42 -0
- package/extensions/web-reach/rsc.ts +161 -0
- package/extensions/web-reach/search.ts +85 -0
- package/extensions/web-reach/transport.ts +35 -0
- package/extensions/windows-wheel-fix/README.md +21 -0
- package/extensions/windows-wheel-fix/index.ts +44 -0
- package/package.json +93 -4
- package/prompts/ask-questions.md +8 -0
- package/prompts/delegate-exploration.md +7 -0
- package/prompts/diagnose-report.md +7 -0
- package/prompts/frontend-tech-preset.md +18 -0
- package/prompts/orchestrator-mode.md +7 -0
- package/prompts/session-kickoff.md +7 -0
- package/prompts/verify-not-assume.md +7 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { complete, readJson, taskId, writeJson } from "./ipc.mjs";
|
|
4
|
+
|
|
5
|
+
const [directory, harness, raw] = process.argv.slice(2);
|
|
6
|
+
if (!directory || !["codex", "claude", "claude-start"].includes(harness)) throw new Error("Invalid multi-harness hook arguments.");
|
|
7
|
+
const event = JSON.parse(harness === "codex" ? raw : readFileSync(0, "utf8"));
|
|
8
|
+
const request = readJson(join(directory, "request.json"));
|
|
9
|
+
if (!request) process.exit(0);
|
|
10
|
+
if (harness === "claude-start") {
|
|
11
|
+
if (event.hook_event_name !== "UserPromptSubmit") process.exit(0);
|
|
12
|
+
writeJson(join(directory, "state.json"), { status: "running", id: taskId(event.prompt), at: Date.now() });
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
if (harness === "codex" && event.type !== "agent-turn-complete") process.exit(0);
|
|
16
|
+
if (harness === "claude" && event.hook_event_name !== "Stop") process.exit(0);
|
|
17
|
+
const sessionId = event["thread-id"] ?? event.thread_id ?? event.session_id;
|
|
18
|
+
const inputs = event["input-messages"] ?? event.input_messages;
|
|
19
|
+
const state = readJson(join(directory, "state.json"));
|
|
20
|
+
const id = harness === "codex"
|
|
21
|
+
? (Array.isArray(inputs) ? inputs.map(taskId).filter(Boolean).at(-1) : request.id)
|
|
22
|
+
: state?.id;
|
|
23
|
+
if (sessionId) writeJson(join(directory, "metadata.json"), { sessionId, harness });
|
|
24
|
+
if (id !== request.id) {
|
|
25
|
+
// Manual turns must return to idle too, without completing a coordinator dispatch.
|
|
26
|
+
writeJson(join(directory, "state.json"), { status: "idle", at: Date.now() });
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
complete(directory, { id, status: "completed", sessionId, summary: event["last-assistant-message"] ?? event.last_assistant_message });
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { watch, renameSync, unlinkSync, type FSWatcher } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { complete, readJson, taskId, writeJson } from "./ipc.mjs";
|
|
5
|
+
|
|
6
|
+
/** Native Pi inbox and settlement events; never shuts down an idle TUI. */
|
|
7
|
+
export default function workerBridge(pi: ExtensionAPI) {
|
|
8
|
+
const directory = process.env.PI_MULTI_HARNESS_DIR;
|
|
9
|
+
if (process.env.PI_MULTI_HARNESS_WORKER !== "1" || !directory) return;
|
|
10
|
+
let watcher: FSWatcher | undefined;
|
|
11
|
+
let id: string | undefined;
|
|
12
|
+
let summary = "";
|
|
13
|
+
let error: string | undefined;
|
|
14
|
+
const inbox = join(directory, "inbox.json");
|
|
15
|
+
|
|
16
|
+
function receive() {
|
|
17
|
+
let payload: { id?: string; task?: string } | undefined;
|
|
18
|
+
const processing = `${inbox}.processing`;
|
|
19
|
+
try {
|
|
20
|
+
renameSync(inbox, processing);
|
|
21
|
+
payload = readJson(processing);
|
|
22
|
+
unlinkSync(processing);
|
|
23
|
+
if (!payload?.id || !payload.task) throw new Error("Inbox requires id and task.");
|
|
24
|
+
pi.sendUserMessage(payload.task, { deliverAs: "followUp", expandPromptTemplates: false });
|
|
25
|
+
writeJson(join(directory, "ack.json"), { id: payload.id, status: "ok" });
|
|
26
|
+
} catch (cause) {
|
|
27
|
+
if ((cause as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
28
|
+
writeJson(join(directory, "ack.json"), { id: payload?.id, status: "error", error: String(cause) });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
pi.on("session_start", () => {
|
|
32
|
+
watcher = watch(directory, (_event, name) => { if (name?.toString() === "inbox.json") receive(); });
|
|
33
|
+
watcher.on("error", (cause) => console.error(`Multi-harness inbox watcher failed: ${cause.message}`));
|
|
34
|
+
receive();
|
|
35
|
+
});
|
|
36
|
+
pi.on("before_agent_start", (event) => {
|
|
37
|
+
id = event.messageType === "user" ? taskId(event.prompt) : undefined;
|
|
38
|
+
summary = "";
|
|
39
|
+
error = undefined;
|
|
40
|
+
writeJson(join(directory, "state.json"), { status: "running", id, at: Date.now() });
|
|
41
|
+
});
|
|
42
|
+
pi.on("agent_end", (event) => {
|
|
43
|
+
const last = event.messages.filter((message) => message.role === "assistant").at(-1);
|
|
44
|
+
summary = last?.content?.filter((part) => part.type === "text").map((part) => part.text).join("\n") ?? "";
|
|
45
|
+
error = last?.stopReason === "error" ? last.errorMessage || "Model error" : undefined;
|
|
46
|
+
});
|
|
47
|
+
pi.on("agent_settled", (event) => {
|
|
48
|
+
if (id) complete(directory, { id, status: error || event.aborted ? "failed" : "completed", summary, error: error ?? (event.aborted ? "Turn aborted" : undefined) });
|
|
49
|
+
else writeJson(join(directory, "state.json"), { status: "idle", at: Date.now() });
|
|
50
|
+
});
|
|
51
|
+
pi.on("session_shutdown", () => { watcher?.close(); });
|
|
52
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# oai-subquota
|
|
2
|
+
|
|
3
|
+
Display OpenAI Codex subscription quota, reset windows, and a compact status line using the authenticated `openai-codex` provider.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install npm:@rol1n/oai-subquota
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Also included in `@rol1n/pi-kit`.
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
| Command | Action |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `/oai-subquota codex` | Show the Codex quota summary |
|
|
18
|
+
| `/oai-subquota refresh` | Force a new quota query |
|
|
19
|
+
| `/oai-subquota statusline` | Print compact quota text |
|
|
20
|
+
| `/oai-subquota chatgpt` | Report the parked ChatGPT OAuth integration's status |
|
|
21
|
+
|
|
22
|
+
## Authentication and cache
|
|
23
|
+
|
|
24
|
+
Authenticate through `/login openai-codex`. The extension uses Pi's provider authentication rather than a separate API-key setting. Cached snapshots are stored at `$XDG_CACHE_HOME/oai-subquota/usage.json`, falling back to `~/.cache/oai-subquota/usage.json`. The normal cache lifetime is 60 seconds.
|
|
25
|
+
|
|
26
|
+
The current implementation supports the Codex usage endpoint. General ChatGPT subscription quota lookup is explicitly parked and does not provide a working second quota source. Backend availability and subscription eligibility determine whether fresh quota data can be retrieved.
|
|
27
|
+
|
|
28
|
+
## Source layout
|
|
29
|
+
|
|
30
|
+
`index.ts` registers the command and lifecycle hooks and coordinates refreshes. `auth.ts` reads account claims from the provider token; `api.ts` handles usage requests; `cache.ts` handles snapshot persistence; `snapshot.ts` normalizes API responses and captured headers and merges snapshots. `types.ts` defines the shared data model, and `ui.ts` formats and renders quota output. The entrypoint re-exports the existing public types and formatting helpers.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isRecord } from "./snapshot.ts";
|
|
2
|
+
|
|
3
|
+
const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
5
|
+
|
|
6
|
+
export async function queryUsage(accessToken: string, accountId?: string): Promise<Record<string, unknown>> {
|
|
7
|
+
const headers: Record<string, string> = {
|
|
8
|
+
Authorization: `Bearer ${accessToken}`,
|
|
9
|
+
"User-Agent": "codex-cli",
|
|
10
|
+
Accept: "application/json",
|
|
11
|
+
};
|
|
12
|
+
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
|
13
|
+
|
|
14
|
+
const res = await fetch(USAGE_ENDPOINT, {
|
|
15
|
+
method: "GET",
|
|
16
|
+
headers,
|
|
17
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (res.status === 401 || res.status === 403) throw new Error(`Authentication rejected (HTTP ${res.status}). Please run /login openai-codex.`);
|
|
21
|
+
if (res.status === 429) throw new Error("Rate limit exceeded for usage endpoint (HTTP 429). Please try again later.");
|
|
22
|
+
if (!res.ok) throw new Error(`Usage request failed (HTTP ${res.status}).`);
|
|
23
|
+
|
|
24
|
+
const body = await res.json();
|
|
25
|
+
if (!isRecord(body)) throw new Error("Invalid JSON received from usage endpoint.");
|
|
26
|
+
return body;
|
|
27
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { isRecord } from "./snapshot.ts";
|
|
3
|
+
import type { AccountSnapshot } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const JWT_AUTH_CLAIM = "https://api.openai.com/auth";
|
|
6
|
+
const JWT_PROFILE_CLAIM = "https://api.openai.com/profile";
|
|
7
|
+
|
|
8
|
+
function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
|
|
9
|
+
const part = token.split(".")[1];
|
|
10
|
+
if (!part) return undefined;
|
|
11
|
+
try {
|
|
12
|
+
const json = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
13
|
+
return isRecord(json) ? json : undefined;
|
|
14
|
+
} catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function extractAccountFromToken(token: string): AccountSnapshot {
|
|
20
|
+
const payload = decodeJwtPayload(token);
|
|
21
|
+
if (!payload) return {};
|
|
22
|
+
|
|
23
|
+
const auth = isRecord(payload[JWT_AUTH_CLAIM]) ? payload[JWT_AUTH_CLAIM] : undefined;
|
|
24
|
+
const profile = isRecord(payload[JWT_PROFILE_CLAIM]) ? payload[JWT_PROFILE_CLAIM] : undefined;
|
|
25
|
+
|
|
26
|
+
const accountId = typeof auth?.chatgpt_account_id === "string" ? auth.chatgpt_account_id : undefined;
|
|
27
|
+
const plan = typeof auth?.chatgpt_plan_type === "string" ? auth.chatgpt_plan_type : undefined;
|
|
28
|
+
const email =
|
|
29
|
+
(typeof profile?.email === "string" && profile.email) ||
|
|
30
|
+
(typeof payload.email === "string" && payload.email) ||
|
|
31
|
+
undefined;
|
|
32
|
+
|
|
33
|
+
return { accountId, plan, email };
|
|
34
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { isRecord } from "./snapshot.ts";
|
|
5
|
+
import type { CodexUsageSnapshot } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
function getCachePath(): string {
|
|
8
|
+
const base = process.env.XDG_CACHE_HOME || join(homedir() || ".", ".cache");
|
|
9
|
+
return join(base, "oai-subquota", "usage.json");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function readCachedSnapshot(): Promise<CodexUsageSnapshot | undefined> {
|
|
13
|
+
try {
|
|
14
|
+
const raw = await readFile(getCachePath(), "utf8");
|
|
15
|
+
const parsed = JSON.parse(raw);
|
|
16
|
+
return isRecord(parsed) ? (parsed as unknown as CodexUsageSnapshot) : undefined;
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function writeCachedSnapshot(snapshot: CodexUsageSnapshot): Promise<void> {
|
|
23
|
+
try {
|
|
24
|
+
const file = getCachePath();
|
|
25
|
+
await mkdir(dirname(file), { recursive: true });
|
|
26
|
+
await writeFile(file, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 });
|
|
27
|
+
} catch {
|
|
28
|
+
// Non-fatal if filesystem is read-only
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oai-subquota — OpenAI Codex subscription quota & status monitor for Pi
|
|
3
|
+
*
|
|
4
|
+
* Capabilities:
|
|
5
|
+
* - Queries ChatGPT Codex backend usage quota (5-hour window, 7-day weekly window,
|
|
6
|
+
* banked rate-limit resets, and flexible credits).
|
|
7
|
+
* - Renders a boxed quota summary in chat transcript on `/oai-subquota [codex]`.
|
|
8
|
+
* - Displays a persistent, right-aligned single-line status in the footer:
|
|
9
|
+
* 5h:100% 7d:71% ↺4d5h
|
|
10
|
+
* - Provider-scoped: active exclusively when current model provider is "openai-codex".
|
|
11
|
+
* Automatically clears when switching away and restores upon switching back.
|
|
12
|
+
* - Turn-based hot refresh: passively collects x-codex-* headers during agent loops
|
|
13
|
+
* and refreshes the footer on turn completion (agent_end / agent_settled).
|
|
14
|
+
* - ChatGPT OAuth (provider: "openai") quota querying is parked pending official API.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { AutocompleteItem, ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { extractAccountFromToken } from "./auth.ts";
|
|
19
|
+
import { queryUsage } from "./api.ts";
|
|
20
|
+
import { readCachedSnapshot, writeCachedSnapshot } from "./cache.ts";
|
|
21
|
+
import { mergeSnapshots, parseCodexRateLimitHeaders, parseUsageApiResponse } from "./snapshot.ts";
|
|
22
|
+
import { dimText, formatBoxedStatus, formatRightAlignedStatusline, formatStatusline, renderStatusMessage } from "./ui.ts";
|
|
23
|
+
import type { CodexUsageSnapshot } from "./types.ts";
|
|
24
|
+
|
|
25
|
+
export type { LimitWindow, RateLimit, CreditsSnapshot, ResetsSnapshot, AccountSnapshot, CodexUsageSnapshot } from "./types.ts";
|
|
26
|
+
export { bar, formatDuration, formatStatusline, formatBoxedStatus, formatRightAlignedStatusline } from "./ui.ts";
|
|
27
|
+
|
|
28
|
+
const CODEX_PROVIDER = "openai-codex";
|
|
29
|
+
const CHATGPT_PROVIDER = "openai";
|
|
30
|
+
const STATUS_KEY = "oai-subquota";
|
|
31
|
+
const MESSAGE_TYPE = "codex-quota";
|
|
32
|
+
const CACHE_TTL_MS = 60_000;
|
|
33
|
+
|
|
34
|
+
function isCodexProvider(ctx: ExtensionContext | ExtensionCommandContext): boolean {
|
|
35
|
+
return ctx.model?.provider === CODEX_PROVIDER;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let activeCtx: ExtensionContext | ExtensionCommandContext | null = null;
|
|
39
|
+
let latestSnapshot: CodexUsageSnapshot | undefined;
|
|
40
|
+
|
|
41
|
+
function setFooterStatus(ctx: ExtensionContext | ExtensionCommandContext, snapshot: CodexUsageSnapshot | undefined): void {
|
|
42
|
+
latestSnapshot = snapshot;
|
|
43
|
+
activeCtx = ctx;
|
|
44
|
+
try {
|
|
45
|
+
if (!ctx.ui?.setStatus) return;
|
|
46
|
+
if (!snapshot || !isCodexProvider(ctx)) {
|
|
47
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
ctx.ui.setStatus(STATUS_KEY, dimText(ctx, formatRightAlignedStatusline(snapshot)));
|
|
51
|
+
} catch {
|
|
52
|
+
// UI unavailable
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function refreshCodexSnapshot(
|
|
57
|
+
ctx: ExtensionContext | ExtensionCommandContext,
|
|
58
|
+
force = false,
|
|
59
|
+
): Promise<CodexUsageSnapshot | undefined> {
|
|
60
|
+
const cached = await readCachedSnapshot();
|
|
61
|
+
if (cached) {
|
|
62
|
+
setFooterStatus(ctx, cached);
|
|
63
|
+
if (!force && Date.now() - Date.parse(cached.fetchedAt) < CACHE_TTL_MS) {
|
|
64
|
+
return cached;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const resolved = await ctx.modelRegistry.getProviderAuth(CODEX_PROVIDER).catch(() => undefined);
|
|
69
|
+
const accessToken = resolved?.auth.apiKey;
|
|
70
|
+
if (!accessToken) {
|
|
71
|
+
if (!cached) setFooterStatus(ctx, undefined);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const tokenAccount = extractAccountFromToken(accessToken);
|
|
76
|
+
const rawBody = await queryUsage(accessToken, tokenAccount.accountId);
|
|
77
|
+
const snapshot = parseUsageApiResponse(rawBody, tokenAccount);
|
|
78
|
+
|
|
79
|
+
await writeCachedSnapshot(snapshot);
|
|
80
|
+
setFooterStatus(ctx, snapshot);
|
|
81
|
+
return snapshot;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ----------------- Extension Entrypoint -----------------
|
|
85
|
+
|
|
86
|
+
export default function (pi: ExtensionAPI): void {
|
|
87
|
+
pi.registerMessageRenderer(MESSAGE_TYPE, renderStatusMessage);
|
|
88
|
+
|
|
89
|
+
const onResize = () => {
|
|
90
|
+
if (activeCtx && latestSnapshot && isCodexProvider(activeCtx)) {
|
|
91
|
+
setFooterStatus(activeCtx, latestSnapshot);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
process.stdout?.on?.("resize", onResize);
|
|
95
|
+
|
|
96
|
+
let queryInFlight = false;
|
|
97
|
+
let isLoopActive = false;
|
|
98
|
+
let latestLoopHeaders: Record<string, string> | null = null;
|
|
99
|
+
let lastLoopRefreshTime = 0;
|
|
100
|
+
|
|
101
|
+
pi.on("session_shutdown", () => {
|
|
102
|
+
process.stdout?.off?.("resize", onResize);
|
|
103
|
+
activeCtx = null;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Session start: activate only if current model provider is openai-codex
|
|
107
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
108
|
+
if (!isCodexProvider(ctx)) {
|
|
109
|
+
setFooterStatus(ctx, undefined);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const cached = await readCachedSnapshot();
|
|
114
|
+
if (cached) setFooterStatus(ctx, cached);
|
|
115
|
+
void refreshCodexSnapshot(ctx, false).catch(() => undefined);
|
|
116
|
+
} catch {
|
|
117
|
+
// Non-blocking
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Dynamically react to model selection changes (/model or Ctrl+P)
|
|
122
|
+
pi.on("model_select", async (event, ctx) => {
|
|
123
|
+
if (event.model.provider === CODEX_PROVIDER) {
|
|
124
|
+
try {
|
|
125
|
+
const cached = await readCachedSnapshot();
|
|
126
|
+
if (cached) setFooterStatus(ctx, cached);
|
|
127
|
+
void refreshCodexSnapshot(ctx, false).catch(() => undefined);
|
|
128
|
+
} catch {
|
|
129
|
+
// Non-blocking
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
setFooterStatus(ctx, undefined);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Turn start: activate hot-capture only for openai-codex
|
|
137
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
138
|
+
if (!isCodexProvider(ctx)) {
|
|
139
|
+
isLoopActive = false;
|
|
140
|
+
latestLoopHeaders = null;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
isLoopActive = true;
|
|
144
|
+
latestLoopHeaders = null;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Provider response: passively buffer rate limit headers during turn
|
|
148
|
+
pi.on("after_provider_response", (event, ctx) => {
|
|
149
|
+
if (!isCodexProvider(ctx) || !isLoopActive || !event.headers) return;
|
|
150
|
+
for (const [k, v] of Object.entries(event.headers)) {
|
|
151
|
+
if (k.toLowerCase().startsWith("x-codex-")) {
|
|
152
|
+
if (!latestLoopHeaders) latestLoopHeaders = {};
|
|
153
|
+
latestLoopHeaders[k] = v;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Turn completion: refresh quota on agent loop boundary
|
|
159
|
+
const onAgentLoopFinished = async (ctx: ExtensionContext) => {
|
|
160
|
+
if (!isCodexProvider(ctx)) {
|
|
161
|
+
isLoopActive = false;
|
|
162
|
+
latestLoopHeaders = null;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
isLoopActive = false;
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
if (now - lastLoopRefreshTime < 1500) return;
|
|
168
|
+
lastLoopRefreshTime = now;
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
if (latestLoopHeaders) {
|
|
172
|
+
const parsed = parseCodexRateLimitHeaders(latestLoopHeaders);
|
|
173
|
+
latestLoopHeaders = null;
|
|
174
|
+
if (parsed) {
|
|
175
|
+
const cached = await readCachedSnapshot();
|
|
176
|
+
const merged = mergeSnapshots(cached, parsed);
|
|
177
|
+
await writeCachedSnapshot(merged);
|
|
178
|
+
setFooterStatus(ctx, merged);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
void refreshCodexSnapshot(ctx, false).catch(() => undefined);
|
|
183
|
+
} catch {
|
|
184
|
+
// Non-blocking
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
189
|
+
void onAgentLoopFinished(ctx);
|
|
190
|
+
});
|
|
191
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
192
|
+
void onAgentLoopFinished(ctx);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Slash command registration with English schema
|
|
196
|
+
pi.registerCommand("oai-subquota", {
|
|
197
|
+
description: "Check OpenAI Codex subscription quota (5h, 7d, resets); new ChatGPT OAuth quota is parked",
|
|
198
|
+
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
|
|
199
|
+
const items: AutocompleteItem[] = [
|
|
200
|
+
{ value: "codex", label: "codex", description: "Display Codex quota summary box and refresh footer" },
|
|
201
|
+
{ value: "refresh", label: "refresh", description: "Force fresh quota query from OpenAI backend" },
|
|
202
|
+
{ value: "statusline", label: "statusline", description: "Print compact single-line quota text" },
|
|
203
|
+
{ value: "chatgpt", label: "chatgpt", description: "Inspect new ChatGPT OAuth status (parked)" },
|
|
204
|
+
];
|
|
205
|
+
const filtered = items.filter((item) => item.value.startsWith(prefix.trimStart()));
|
|
206
|
+
return filtered.length > 0 ? filtered : null;
|
|
207
|
+
},
|
|
208
|
+
handler: async (args, ctx) => {
|
|
209
|
+
const sub = args.trim().toLowerCase();
|
|
210
|
+
|
|
211
|
+
// ChatGPT new provider parked placeholder
|
|
212
|
+
if (sub === "chatgpt") {
|
|
213
|
+
const resolved = await ctx.modelRegistry.getProviderAuth(CHATGPT_PROVIDER).catch(() => undefined);
|
|
214
|
+
const hasAuth = !!resolved?.auth.apiKey;
|
|
215
|
+
const msg = hasAuth
|
|
216
|
+
? "ChatGPT subscription (provider: openai) is parked: official 5h/weekly rate limit endpoint is not yet supported."
|
|
217
|
+
: "ChatGPT subscription (provider: openai) is not authenticated. Quota querying is parked.";
|
|
218
|
+
|
|
219
|
+
if (ctx.hasUI) {
|
|
220
|
+
ctx.ui.notify(msg, "info");
|
|
221
|
+
} else {
|
|
222
|
+
console.log(msg);
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (queryInFlight) {
|
|
228
|
+
if (ctx.hasUI) ctx.ui.notify("oai-subquota: Query in progress.", "warning");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
queryInFlight = true;
|
|
233
|
+
try {
|
|
234
|
+
const force = sub === "refresh";
|
|
235
|
+
const snapshot = await refreshCodexSnapshot(ctx, force);
|
|
236
|
+
if (!snapshot) {
|
|
237
|
+
throw new Error("No openai-codex OAuth credentials found. Please run /login openai-codex first.");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (sub === "statusline") {
|
|
241
|
+
const line = formatStatusline(snapshot);
|
|
242
|
+
if (ctx.hasUI === false) {
|
|
243
|
+
console.log(line);
|
|
244
|
+
} else {
|
|
245
|
+
ctx.ui.notify(line, "info");
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Render boxed quota summary
|
|
251
|
+
const boxed = formatBoxedStatus(snapshot);
|
|
252
|
+
if (ctx.hasUI === false) {
|
|
253
|
+
console.log(boxed);
|
|
254
|
+
} else if (pi.sendMessage) {
|
|
255
|
+
await pi.sendMessage({
|
|
256
|
+
customType: MESSAGE_TYPE,
|
|
257
|
+
content: boxed,
|
|
258
|
+
display: true,
|
|
259
|
+
details: snapshot,
|
|
260
|
+
});
|
|
261
|
+
} else {
|
|
262
|
+
ctx.ui.notify(formatStatusline(snapshot), "info");
|
|
263
|
+
}
|
|
264
|
+
} catch (err) {
|
|
265
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
266
|
+
if (ctx.hasUI) {
|
|
267
|
+
ctx.ui.notify(`oai-subquota error: ${message}`, "error");
|
|
268
|
+
} else {
|
|
269
|
+
console.error(`oai-subquota error: ${message}`);
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
queryInFlight = false;
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { AccountSnapshot, CodexUsageSnapshot, CreditsSnapshot, LimitWindow, ResetsSnapshot } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export function isRecord(val: unknown): val is Record<string, unknown> {
|
|
4
|
+
return typeof val === "object" && val !== null && !Array.isArray(val);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function normalizeResetAt(val: unknown): number | undefined {
|
|
8
|
+
if (typeof val !== "number" || !Number.isFinite(val) || val <= 0) return undefined;
|
|
9
|
+
return val > 10_000_000_000 ? Math.round(val / 1000) : val;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeWindow(val: unknown, nowMs = Date.now()): LimitWindow | undefined {
|
|
13
|
+
if (!isRecord(val)) return undefined;
|
|
14
|
+
|
|
15
|
+
const rawUsed = typeof val.used_percent === "number" ? val.used_percent : val.usedPercent;
|
|
16
|
+
if (typeof rawUsed !== "number" || !Number.isFinite(rawUsed) || rawUsed < 0) return undefined;
|
|
17
|
+
|
|
18
|
+
const usedPercent = Math.min(100, Math.max(0, rawUsed));
|
|
19
|
+
const leftPercent = Math.max(0, Math.min(100, 100 - usedPercent));
|
|
20
|
+
|
|
21
|
+
const resetAt = normalizeResetAt(val.reset_at ?? val.resetAt);
|
|
22
|
+
const rawAfter = typeof val.reset_after_seconds === "number" ? val.reset_after_seconds : val.resetAfterSeconds;
|
|
23
|
+
const resetAfterSeconds =
|
|
24
|
+
typeof rawAfter === "number" && Number.isFinite(rawAfter) && rawAfter >= 0
|
|
25
|
+
? rawAfter
|
|
26
|
+
: resetAt !== undefined
|
|
27
|
+
? Math.max(0, Math.round(resetAt - nowMs / 1000))
|
|
28
|
+
: undefined;
|
|
29
|
+
|
|
30
|
+
const rawSec = typeof val.limit_window_seconds === "number" ? val.limit_window_seconds : val.windowSeconds;
|
|
31
|
+
const rawMin = typeof val.window_minutes === "number" ? val.window_minutes : val.windowMinutes;
|
|
32
|
+
const windowSeconds =
|
|
33
|
+
typeof rawSec === "number" && rawSec > 0
|
|
34
|
+
? rawSec
|
|
35
|
+
: typeof rawMin === "number" && rawMin > 0
|
|
36
|
+
? rawMin * 60
|
|
37
|
+
: undefined;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
usedPercent,
|
|
41
|
+
leftPercent,
|
|
42
|
+
...(windowSeconds !== undefined ? { windowSeconds } : {}),
|
|
43
|
+
...(resetAt !== undefined ? { resetAt } : {}),
|
|
44
|
+
...(resetAfterSeconds !== undefined ? { resetAfterSeconds } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseCredits(val: unknown): CreditsSnapshot | undefined {
|
|
49
|
+
if (!isRecord(val)) return undefined;
|
|
50
|
+
const hasCredits = val.has_credits === true;
|
|
51
|
+
const unlimited = val.unlimited === true;
|
|
52
|
+
const rawBalance = val.balance;
|
|
53
|
+
const balance =
|
|
54
|
+
typeof rawBalance === "number"
|
|
55
|
+
? rawBalance
|
|
56
|
+
: typeof rawBalance === "string"
|
|
57
|
+
? Number(rawBalance.trim())
|
|
58
|
+
: undefined;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
hasCredits,
|
|
62
|
+
unlimited,
|
|
63
|
+
...(balance !== undefined && Number.isFinite(balance) ? { balance } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseResets(val: unknown): ResetsSnapshot | undefined {
|
|
68
|
+
if (!isRecord(val)) return undefined;
|
|
69
|
+
const available = typeof val.available_count === "number" ? val.available_count : undefined;
|
|
70
|
+
const applicable = typeof val.applicable_available_count === "number" ? val.applicable_available_count : undefined;
|
|
71
|
+
if (available === undefined && applicable === undefined) return undefined;
|
|
72
|
+
return {
|
|
73
|
+
...(available !== undefined ? { availableCount: available } : {}),
|
|
74
|
+
...(applicable !== undefined ? { applicableCount: applicable } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseUsageApiResponse(data: unknown, tokenAccount: AccountSnapshot): CodexUsageSnapshot {
|
|
79
|
+
if (!isRecord(data)) throw new Error("Usage API returned non-object response");
|
|
80
|
+
|
|
81
|
+
const rateLimit = isRecord(data.rate_limit) ? data.rate_limit : undefined;
|
|
82
|
+
const primary = normalizeWindow(rateLimit?.primary_window);
|
|
83
|
+
const secondary = normalizeWindow(rateLimit?.secondary_window);
|
|
84
|
+
|
|
85
|
+
const email = (typeof data.email === "string" && data.email) || tokenAccount.email;
|
|
86
|
+
const plan = (typeof data.plan_type === "string" && data.plan_type) || tokenAccount.plan;
|
|
87
|
+
const accountId = (typeof data.account_id === "string" && data.account_id) || tokenAccount.accountId;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
source: "api",
|
|
91
|
+
fetchedAt: new Date().toISOString(),
|
|
92
|
+
account: { email, plan, accountId },
|
|
93
|
+
defaultLimit: {
|
|
94
|
+
id: "codex",
|
|
95
|
+
name: "Codex",
|
|
96
|
+
...(primary ? { primary } : {}),
|
|
97
|
+
...(secondary ? { secondary } : {}),
|
|
98
|
+
},
|
|
99
|
+
credits: parseCredits(data.credits),
|
|
100
|
+
resets: parseResets(data.rate_limit_reset_credits),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function parseCodexRateLimitHeaders(headers: Record<string, string>): CodexUsageSnapshot | undefined {
|
|
105
|
+
const norm: Record<string, string> = {};
|
|
106
|
+
for (const [k, v] of Object.entries(headers)) norm[k.toLowerCase()] = String(v);
|
|
107
|
+
|
|
108
|
+
const num = (name: string): number | undefined => {
|
|
109
|
+
const v = norm[name];
|
|
110
|
+
if (v === undefined || v === "") return undefined;
|
|
111
|
+
const n = Number(v);
|
|
112
|
+
return Number.isFinite(n) ? n : undefined;
|
|
113
|
+
};
|
|
114
|
+
const bool = (name: string): boolean | undefined => {
|
|
115
|
+
const v = norm[name]?.trim().toLowerCase();
|
|
116
|
+
return v === "true" || v === "1" ? true : v === "false" || v === "0" ? false : undefined;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const primaryUsed = num("x-codex-primary-used-percent");
|
|
120
|
+
const secondaryUsed = num("x-codex-secondary-used-percent");
|
|
121
|
+
if (primaryUsed === undefined && secondaryUsed === undefined) return undefined;
|
|
122
|
+
|
|
123
|
+
const primary = normalizeWindow({
|
|
124
|
+
used_percent: primaryUsed,
|
|
125
|
+
window_minutes: num("x-codex-primary-window-minutes"),
|
|
126
|
+
reset_at: normalizeResetAt(num("x-codex-primary-reset-at")),
|
|
127
|
+
});
|
|
128
|
+
const secondary = normalizeWindow({
|
|
129
|
+
used_percent: secondaryUsed,
|
|
130
|
+
window_minutes: num("x-codex-secondary-window-minutes"),
|
|
131
|
+
reset_at: normalizeResetAt(num("x-codex-secondary-reset-at")),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
source: "headers",
|
|
136
|
+
fetchedAt: new Date().toISOString(),
|
|
137
|
+
account: {},
|
|
138
|
+
defaultLimit: {
|
|
139
|
+
id: "codex",
|
|
140
|
+
name: "Codex",
|
|
141
|
+
...(primary ? { primary } : {}),
|
|
142
|
+
...(secondary ? { secondary } : {}),
|
|
143
|
+
},
|
|
144
|
+
credits: {
|
|
145
|
+
hasCredits: bool("x-codex-credits-has-credits"),
|
|
146
|
+
unlimited: bool("x-codex-credits-unlimited"),
|
|
147
|
+
balance: num("x-codex-credits-balance"),
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function mergeSnapshots(existing: CodexUsageSnapshot | undefined, update: CodexUsageSnapshot): CodexUsageSnapshot {
|
|
153
|
+
if (!existing) return update;
|
|
154
|
+
const defaultLimit = update.defaultLimit ?? existing.defaultLimit;
|
|
155
|
+
if (defaultLimit && existing.defaultLimit) {
|
|
156
|
+
defaultLimit.primary = update.defaultLimit?.primary ?? existing.defaultLimit.primary;
|
|
157
|
+
defaultLimit.secondary = update.defaultLimit?.secondary ?? existing.defaultLimit.secondary;
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
...existing,
|
|
161
|
+
...update,
|
|
162
|
+
account: { ...existing.account, ...update.account },
|
|
163
|
+
...(defaultLimit ? { defaultLimit } : {}),
|
|
164
|
+
credits: update.credits ?? existing.credits,
|
|
165
|
+
resets: update.resets ?? existing.resets,
|
|
166
|
+
};
|
|
167
|
+
}
|