@andrewting19/oracle 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +300 -0
- package/assets-oracle-icon.png +0 -0
- package/dist/bin/oracle-cli.js +1480 -0
- package/dist/bin/oracle-mcp.js +6 -0
- package/dist/scripts/agent-send.js +147 -0
- package/dist/scripts/browser-tools.js +536 -0
- package/dist/scripts/check.js +21 -0
- package/dist/scripts/debug/extract-chatgpt-response.js +53 -0
- package/dist/scripts/docs-list.js +110 -0
- package/dist/scripts/git-policy.js +127 -0
- package/dist/scripts/run-cli.js +14 -0
- package/dist/scripts/runner.js +1386 -0
- package/dist/scripts/test-browser.js +106 -0
- package/dist/scripts/test-remote-chrome.js +68 -0
- package/dist/src/bridge/connection.js +103 -0
- package/dist/src/bridge/userConfigFile.js +28 -0
- package/dist/src/browser/actions/assistantResponse.js +1085 -0
- package/dist/src/browser/actions/attachmentDataTransfer.js +140 -0
- package/dist/src/browser/actions/attachments.js +1939 -0
- package/dist/src/browser/actions/domEvents.js +19 -0
- package/dist/src/browser/actions/modelSelection.js +549 -0
- package/dist/src/browser/actions/navigation.js +451 -0
- package/dist/src/browser/actions/promptComposer.js +487 -0
- package/dist/src/browser/actions/remoteFileTransfer.js +37 -0
- package/dist/src/browser/actions/thinkingTime.js +206 -0
- package/dist/src/browser/chromeLifecycle.js +346 -0
- package/dist/src/browser/config.js +105 -0
- package/dist/src/browser/constants.js +75 -0
- package/dist/src/browser/cookies.js +191 -0
- package/dist/src/browser/detect.js +164 -0
- package/dist/src/browser/domDebug.js +36 -0
- package/dist/src/browser/index.js +1826 -0
- package/dist/src/browser/modelStrategy.js +13 -0
- package/dist/src/browser/pageActions.js +5 -0
- package/dist/src/browser/policies.js +46 -0
- package/dist/src/browser/profileState.js +285 -0
- package/dist/src/browser/prompt.js +182 -0
- package/dist/src/browser/promptSummary.js +20 -0
- package/dist/src/browser/providerDomFlow.js +17 -0
- package/dist/src/browser/providers/chatgptDomProvider.js +49 -0
- package/dist/src/browser/providers/geminiDeepThinkDomProvider.js +254 -0
- package/dist/src/browser/providers/index.js +2 -0
- package/dist/src/browser/reattach.js +189 -0
- package/dist/src/browser/reattachHelpers.js +387 -0
- package/dist/src/browser/sessionRunner.js +131 -0
- package/dist/src/browser/types.js +1 -0
- package/dist/src/browser/utils.js +122 -0
- package/dist/src/browserMode.js +1 -0
- package/dist/src/cli/bridge/claudeConfig.js +54 -0
- package/dist/src/cli/bridge/client.js +81 -0
- package/dist/src/cli/bridge/codexConfig.js +43 -0
- package/dist/src/cli/bridge/doctor.js +115 -0
- package/dist/src/cli/bridge/host.js +261 -0
- package/dist/src/cli/browserConfig.js +293 -0
- package/dist/src/cli/browserDefaults.js +82 -0
- package/dist/src/cli/bundleWarnings.js +9 -0
- package/dist/src/cli/clipboard.js +10 -0
- package/dist/src/cli/detach.js +14 -0
- package/dist/src/cli/dryRun.js +109 -0
- package/dist/src/cli/duplicatePromptGuard.js +14 -0
- package/dist/src/cli/engine.js +41 -0
- package/dist/src/cli/errorUtils.js +9 -0
- package/dist/src/cli/fileSize.js +11 -0
- package/dist/src/cli/format.js +13 -0
- package/dist/src/cli/help.js +77 -0
- package/dist/src/cli/hiddenAliases.js +22 -0
- package/dist/src/cli/markdownBundle.js +21 -0
- package/dist/src/cli/markdownRenderer.js +97 -0
- package/dist/src/cli/notifier.js +316 -0
- package/dist/src/cli/options.js +305 -0
- package/dist/src/cli/oscUtils.js +2 -0
- package/dist/src/cli/promptRequirement.js +17 -0
- package/dist/src/cli/renderFlags.js +9 -0
- package/dist/src/cli/renderOutput.js +26 -0
- package/dist/src/cli/rootAlias.js +30 -0
- package/dist/src/cli/runOptions.js +90 -0
- package/dist/src/cli/sessionCommand.js +121 -0
- package/dist/src/cli/sessionDisplay.js +670 -0
- package/dist/src/cli/sessionLineage.js +60 -0
- package/dist/src/cli/sessionRunner.js +630 -0
- package/dist/src/cli/sessionTable.js +96 -0
- package/dist/src/cli/tagline.js +255 -0
- package/dist/src/cli/tui/index.js +499 -0
- package/dist/src/cli/writeOutputPath.js +21 -0
- package/dist/src/config.js +26 -0
- package/dist/src/gemini-web/browserSessionManager.js +81 -0
- package/dist/src/gemini-web/client.js +339 -0
- package/dist/src/gemini-web/executionClients.js +1 -0
- package/dist/src/gemini-web/executionMode.js +16 -0
- package/dist/src/gemini-web/executor.js +443 -0
- package/dist/src/gemini-web/index.js +1 -0
- package/dist/src/gemini-web/types.js +1 -0
- package/dist/src/heartbeat.js +43 -0
- package/dist/src/mcp/server.js +40 -0
- package/dist/src/mcp/tools/consult.js +307 -0
- package/dist/src/mcp/tools/sessionResources.js +75 -0
- package/dist/src/mcp/tools/sessions.js +114 -0
- package/dist/src/mcp/types.js +22 -0
- package/dist/src/mcp/utils.js +45 -0
- package/dist/src/oracle/background.js +141 -0
- package/dist/src/oracle/claude.js +107 -0
- package/dist/src/oracle/client.js +235 -0
- package/dist/src/oracle/config.js +192 -0
- package/dist/src/oracle/errors.js +132 -0
- package/dist/src/oracle/files.js +402 -0
- package/dist/src/oracle/finishLine.js +34 -0
- package/dist/src/oracle/format.js +30 -0
- package/dist/src/oracle/fsAdapter.js +10 -0
- package/dist/src/oracle/gemini.js +194 -0
- package/dist/src/oracle/logging.js +36 -0
- package/dist/src/oracle/markdown.js +46 -0
- package/dist/src/oracle/modelResolver.js +183 -0
- package/dist/src/oracle/multiModelRunner.js +153 -0
- package/dist/src/oracle/oscProgress.js +24 -0
- package/dist/src/oracle/promptAssembly.js +16 -0
- package/dist/src/oracle/request.js +58 -0
- package/dist/src/oracle/run.js +628 -0
- package/dist/src/oracle/runUtils.js +34 -0
- package/dist/src/oracle/tokenEstimate.js +37 -0
- package/dist/src/oracle/tokenStats.js +39 -0
- package/dist/src/oracle/tokenStringifier.js +24 -0
- package/dist/src/oracle/types.js +1 -0
- package/dist/src/oracle.js +12 -0
- package/dist/src/oracleHome.js +13 -0
- package/dist/src/remote/client.js +129 -0
- package/dist/src/remote/health.js +113 -0
- package/dist/src/remote/remoteServiceConfig.js +31 -0
- package/dist/src/remote/server.js +544 -0
- package/dist/src/remote/types.js +1 -0
- package/dist/src/sessionManager.js +643 -0
- package/dist/src/sessionStore.js +56 -0
- package/dist/src/version.js +39 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
- package/dist/vendor/oracle-notifier/README.md +26 -0
- package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
- package/package.json +120 -0
- package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
- package/vendor/oracle-notifier/README.md +26 -0
- package/vendor/oracle-notifier/build-notifier.sh +93 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import kleur from "kleur";
|
|
2
|
+
const createColorWrapper = (isTty) => (styler) => (text) => isTty ? styler(text) : text;
|
|
3
|
+
export function applyHelpStyling(program, version, isTty) {
|
|
4
|
+
const wrap = createColorWrapper(isTty);
|
|
5
|
+
const colors = {
|
|
6
|
+
banner: wrap((text) => kleur.bold().blue(text)),
|
|
7
|
+
subtitle: wrap((text) => kleur.dim(text)),
|
|
8
|
+
section: wrap((text) => kleur.bold().white(text)),
|
|
9
|
+
bullet: wrap((text) => kleur.blue(text)),
|
|
10
|
+
command: wrap((text) => kleur.bold().blue(text)),
|
|
11
|
+
option: wrap((text) => kleur.cyan(text)),
|
|
12
|
+
argument: wrap((text) => kleur.magenta(text)),
|
|
13
|
+
description: wrap((text) => kleur.white(text)),
|
|
14
|
+
muted: wrap((text) => kleur.gray(text)),
|
|
15
|
+
accent: wrap((text) => kleur.cyan(text)),
|
|
16
|
+
};
|
|
17
|
+
program.configureHelp({
|
|
18
|
+
styleTitle(title) {
|
|
19
|
+
return colors.section(title);
|
|
20
|
+
},
|
|
21
|
+
styleDescriptionText(text) {
|
|
22
|
+
return colors.description(text);
|
|
23
|
+
},
|
|
24
|
+
styleCommandText(text) {
|
|
25
|
+
return colors.command(text);
|
|
26
|
+
},
|
|
27
|
+
styleSubcommandText(text) {
|
|
28
|
+
return colors.command(text);
|
|
29
|
+
},
|
|
30
|
+
styleOptionText(text) {
|
|
31
|
+
return colors.option(text);
|
|
32
|
+
},
|
|
33
|
+
styleArgumentText(text) {
|
|
34
|
+
return colors.argument(text);
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
program.addHelpText("beforeAll", () => renderHelpBanner(version, colors));
|
|
38
|
+
program.addHelpText("after", () => renderHelpFooter(program, colors));
|
|
39
|
+
}
|
|
40
|
+
function renderHelpBanner(version, colors) {
|
|
41
|
+
const subtitle = "Prompt + files required — GPT-5.4 Pro/GPT-5.4 for tough questions with code/file context.";
|
|
42
|
+
return `${colors.banner(`Oracle CLI v${version}`)} ${colors.subtitle(`— ${subtitle}`)}\n`;
|
|
43
|
+
}
|
|
44
|
+
function renderHelpFooter(program, colors) {
|
|
45
|
+
const tips = [
|
|
46
|
+
`${colors.bullet("•")} Required: always pass a prompt AND ${colors.accent("--file …")} (directories/globs are fine); Oracle cannot see your project otherwise.`,
|
|
47
|
+
`${colors.bullet("•")} Attach lots of source (whole directories beat single files) and keep total input under ~196k tokens.`,
|
|
48
|
+
`${colors.bullet("•")} Oracle starts empty—open with a short project briefing (stack, services, build steps), spell out the question and prior attempts, and why it matters; the more explanation and context you provide, the better the response will be.`,
|
|
49
|
+
`${colors.bullet("•")} Spell out the project + platform + version requirements (repo name, target OS/toolchain versions, API dependencies) so Oracle doesn’t guess defaults.`,
|
|
50
|
+
`${colors.bullet("•")} When comparing multiple repos/files, spell out each repo + path + role (e.g., “Project A SettingsView → apps/project-a/Sources/SettingsView.swift; Project B SettingsView → ../project-b/mac/...”) so the model knows exactly which file is which.`,
|
|
51
|
+
`${colors.bullet("•")} Best results: 6–30 sentences plus key source files; very short prompts often yield generic answers.`,
|
|
52
|
+
`${colors.bullet("•")} Oracle is one-shot by default. For OpenAI/Azure API runs, you can chain follow-ups by passing ${colors.accent("--followup <sessionId|responseId>")} (continues via Responses API previous_response_id).`,
|
|
53
|
+
`${colors.bullet("•")} Run ${colors.accent("--files-report")} to inspect token spend before hitting the API.`,
|
|
54
|
+
`${colors.bullet("•")} Non-preview runs spawn detached sessions (especially gpt-5.4-pro API). If the CLI times out, do not re-run — reattach with ${colors.accent("oracle session <slug>")} to resume/inspect the existing run.`,
|
|
55
|
+
`${colors.bullet("•")} Set a memorable 3–5 word slug via ${colors.accent('--slug "<words>"')} to keep session IDs tidy.`,
|
|
56
|
+
`${colors.bullet("•")} Finished sessions auto-hide preamble logs when reattached; raw timestamps remain in the saved log file.`,
|
|
57
|
+
`${colors.bullet("•")} Need hidden flags? Run ${colors.accent(`${program.name()} --help --verbose`)} to list search/token/browser overrides.`,
|
|
58
|
+
`${colors.bullet("•")} If any Oracle session is already running, do not start new API runs. Attach to the existing browser session instead; only trigger API calls when you explicitly mean to.`,
|
|
59
|
+
`${colors.bullet("•")} Duplicate prompt guard: if the same prompt is already running, new runs are blocked unless you pass ${colors.accent("--force")}—prefer reattaching instead of spawning duplicates.`,
|
|
60
|
+
].join("\n");
|
|
61
|
+
const formatExample = (command, description) => `${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`;
|
|
62
|
+
const examples = [
|
|
63
|
+
formatExample(`${program.name()} --render --copy --prompt "Review the TS data layer for schema drift" --file "src/**/*.ts,*/*.test.ts"`, "Build the bundle, print it, and copy it for manual paste into ChatGPT."),
|
|
64
|
+
formatExample(`${program.name()} --prompt "Cross-check the data layer assumptions" --models gpt-5.2-pro,gemini-3-pro --file "src/**/*.ts"`, "Run multiple API models in one go and aggregate cost/usage."),
|
|
65
|
+
formatExample(`${program.name()} status --hours 72 --limit 50`, "Show sessions from the last 72h (capped at 50 entries)."),
|
|
66
|
+
formatExample(`${program.name()} session <sessionId>`, "Attach to a running/completed session and stream the saved transcript."),
|
|
67
|
+
formatExample(`${program.name()} --prompt "Ship review" --slug "release-readiness-audit"`, "Encourage the model to hand you a 3–5 word slug and pass it along with --slug."),
|
|
68
|
+
formatExample(`${program.name()} --prompt "Tabs frozen: compare Project A SettingsView (apps/project-a/Sources/SettingsView.swift) vs Project B SettingsView (../project-b/mac/App/Presentation/Views/SettingsView.swift)" --file apps/project-a/Sources/SettingsView.swift --file ../project-b/mac/App/Presentation/Views/SettingsView.swift`, "Spell out what each attached file is (repo + path + role) before asking for comparisons so the model knows exactly what it is reading."),
|
|
69
|
+
].join("\n\n");
|
|
70
|
+
return `
|
|
71
|
+
${colors.section("Tips")}
|
|
72
|
+
${tips}
|
|
73
|
+
|
|
74
|
+
${colors.section("Examples")}
|
|
75
|
+
${examples}
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize hidden alias flags so they behave like their primary counterparts.
|
|
3
|
+
*
|
|
4
|
+
* - `--message` maps to `--prompt` when no prompt is provided.
|
|
5
|
+
* - `--include` extends the `--file` list.
|
|
6
|
+
* - `--mode` maps to `--engine` for backward compatibility with older docs/UX.
|
|
7
|
+
*/
|
|
8
|
+
export function applyHiddenAliases(options, setOptionValue) {
|
|
9
|
+
if (options.include && options.include.length > 0) {
|
|
10
|
+
const mergedFiles = [...(options.file ?? []), ...options.include];
|
|
11
|
+
options.file = mergedFiles;
|
|
12
|
+
setOptionValue?.("file", mergedFiles);
|
|
13
|
+
}
|
|
14
|
+
if (!options.prompt && options.message) {
|
|
15
|
+
options.prompt = options.message;
|
|
16
|
+
setOptionValue?.("prompt", options.message);
|
|
17
|
+
}
|
|
18
|
+
if (!options.engine && options.mode) {
|
|
19
|
+
options.engine = options.mode;
|
|
20
|
+
setOptionValue?.("engine", options.mode);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { DEFAULT_SYSTEM_PROMPT } from "../oracle/config.js";
|
|
3
|
+
import { buildPrompt } from "../oracle/request.js";
|
|
4
|
+
import { createFileSections, readFiles } from "../oracle/files.js";
|
|
5
|
+
import { createFsAdapter } from "../oracle/fsAdapter.js";
|
|
6
|
+
import { buildPromptMarkdown } from "../oracle/promptAssembly.js";
|
|
7
|
+
export async function buildMarkdownBundle(options, deps = {}) {
|
|
8
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
9
|
+
const fsModule = deps.fs ?? createFsAdapter(fs);
|
|
10
|
+
const files = await readFiles(options.file ?? [], {
|
|
11
|
+
cwd,
|
|
12
|
+
fsModule,
|
|
13
|
+
maxFileSizeBytes: options.maxFileSizeBytes,
|
|
14
|
+
});
|
|
15
|
+
const sections = createFileSections(files, cwd);
|
|
16
|
+
const systemPrompt = options.system?.trim() || DEFAULT_SYSTEM_PROMPT;
|
|
17
|
+
const userPrompt = (options.prompt ?? "").trim();
|
|
18
|
+
const markdown = buildPromptMarkdown(systemPrompt, userPrompt, sections);
|
|
19
|
+
const promptWithFiles = buildPrompt(userPrompt, files, cwd);
|
|
20
|
+
return { markdown, promptWithFiles, systemPrompt, files };
|
|
21
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { render as renderMarkdown } from "markdansi";
|
|
3
|
+
import { bundledLanguages, bundledThemes, createHighlighter } from "shiki";
|
|
4
|
+
const DEFAULT_THEME = "github-dark";
|
|
5
|
+
const HIGHLIGHT_LANGS = ["ts", "tsx", "js", "jsx", "json", "swift"];
|
|
6
|
+
const SUPPORTED_LANG_ALIASES = {
|
|
7
|
+
ts: "ts",
|
|
8
|
+
typescript: "ts",
|
|
9
|
+
tsx: "tsx",
|
|
10
|
+
js: "js",
|
|
11
|
+
javascript: "js",
|
|
12
|
+
jsx: "jsx",
|
|
13
|
+
json: "json",
|
|
14
|
+
swift: "swift",
|
|
15
|
+
};
|
|
16
|
+
const shikiPromise = createHighlighter({
|
|
17
|
+
themes: [bundledThemes[DEFAULT_THEME]],
|
|
18
|
+
langs: HIGHLIGHT_LANGS.map((lang) => bundledLanguages[lang]),
|
|
19
|
+
});
|
|
20
|
+
let shiki = null;
|
|
21
|
+
void shikiPromise
|
|
22
|
+
.then((instance) => {
|
|
23
|
+
shiki = instance;
|
|
24
|
+
})
|
|
25
|
+
.catch(() => {
|
|
26
|
+
shiki = null;
|
|
27
|
+
});
|
|
28
|
+
export async function ensureShikiReady() {
|
|
29
|
+
if (shiki)
|
|
30
|
+
return;
|
|
31
|
+
try {
|
|
32
|
+
shiki = await shikiPromise;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
shiki = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function normalizeLanguage(lang) {
|
|
39
|
+
if (!lang)
|
|
40
|
+
return null;
|
|
41
|
+
const key = lang.toLowerCase();
|
|
42
|
+
return SUPPORTED_LANG_ALIASES[key] ?? null;
|
|
43
|
+
}
|
|
44
|
+
function styleToken(text, fontStyle = 0) {
|
|
45
|
+
let styled = text;
|
|
46
|
+
if (fontStyle & 1)
|
|
47
|
+
styled = chalk.italic(styled);
|
|
48
|
+
if (fontStyle & 2)
|
|
49
|
+
styled = chalk.bold(styled);
|
|
50
|
+
if (fontStyle & 4)
|
|
51
|
+
styled = chalk.underline(styled);
|
|
52
|
+
if (fontStyle & 8)
|
|
53
|
+
styled = chalk.strikethrough(styled);
|
|
54
|
+
return styled;
|
|
55
|
+
}
|
|
56
|
+
function shikiHighlighter(code, lang) {
|
|
57
|
+
if (!process.stdout.isTTY || !shiki)
|
|
58
|
+
return code;
|
|
59
|
+
const normalizedLang = normalizeLanguage(lang);
|
|
60
|
+
if (!normalizedLang)
|
|
61
|
+
return code;
|
|
62
|
+
try {
|
|
63
|
+
if (!shiki.getLoadedLanguages().includes(normalizedLang)) {
|
|
64
|
+
return code;
|
|
65
|
+
}
|
|
66
|
+
const { tokens } = shiki.codeToTokens(code, { lang: normalizedLang, theme: DEFAULT_THEME });
|
|
67
|
+
return tokens
|
|
68
|
+
.map((line) => line
|
|
69
|
+
.map((token) => {
|
|
70
|
+
const colored = token.color ? chalk.hex(token.color)(token.content) : token.content;
|
|
71
|
+
return styleToken(colored, token.fontStyle);
|
|
72
|
+
})
|
|
73
|
+
.join(""))
|
|
74
|
+
.join("\n");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return code;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function renderMarkdownAnsi(markdown) {
|
|
81
|
+
try {
|
|
82
|
+
const color = Boolean(process.stdout.isTTY);
|
|
83
|
+
const width = process.stdout.columns;
|
|
84
|
+
const hyperlinks = color; // enable OSC 8 only when we have color/TTY
|
|
85
|
+
return renderMarkdown(markdown, {
|
|
86
|
+
color,
|
|
87
|
+
width,
|
|
88
|
+
wrap: true,
|
|
89
|
+
hyperlinks,
|
|
90
|
+
highlighter: color ? shikiHighlighter : undefined,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Last-resort fallback: return the raw markdown so we never crash.
|
|
95
|
+
return markdown;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import notifier from "toasted-notifier";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { formatUSD, formatNumber } from "../oracle/format.js";
|
|
4
|
+
import { MODEL_CONFIGS } from "../oracle/config.js";
|
|
5
|
+
import { estimateUsdCost } from "tokentally";
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
const ORACLE_EMOJI = "🧿";
|
|
11
|
+
export function resolveNotificationSettings({ cliNotify, cliNotifySound, env, config, }) {
|
|
12
|
+
const defaultEnabled = !(bool(env.CI) || bool(env.SSH_CONNECTION) || muteByConfig(env, config));
|
|
13
|
+
const envNotify = parseToggle(env.ORACLE_NOTIFY);
|
|
14
|
+
const envSound = parseToggle(env.ORACLE_NOTIFY_SOUND);
|
|
15
|
+
const enabled = cliNotify ?? envNotify ?? config?.enabled ?? defaultEnabled;
|
|
16
|
+
const sound = cliNotifySound ?? envSound ?? config?.sound ?? false;
|
|
17
|
+
return { enabled, sound };
|
|
18
|
+
}
|
|
19
|
+
export function deriveNotificationSettingsFromMetadata(metadata, env, config) {
|
|
20
|
+
if (metadata?.notifications) {
|
|
21
|
+
return metadata.notifications;
|
|
22
|
+
}
|
|
23
|
+
return resolveNotificationSettings({
|
|
24
|
+
cliNotify: undefined,
|
|
25
|
+
cliNotifySound: undefined,
|
|
26
|
+
env,
|
|
27
|
+
config,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export async function sendSessionNotification(payload, settings, log, answerPreview) {
|
|
31
|
+
if (!settings.enabled || isTestEnv(process.env)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const title = `Oracle${ORACLE_EMOJI} finished`;
|
|
35
|
+
const message = buildMessage(payload, sanitizePreview(answerPreview));
|
|
36
|
+
try {
|
|
37
|
+
if (await tryMacNativeNotifier(title, message, settings)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!(await shouldSkipToastedNotifier())) {
|
|
41
|
+
// Fallback to toasted-notifier (cross-platform). macAppIconOption() is only honored on macOS.
|
|
42
|
+
await notifier.notify({
|
|
43
|
+
title,
|
|
44
|
+
message,
|
|
45
|
+
sound: settings.sound,
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (isMacExecError(error)) {
|
|
52
|
+
const repaired = await repairMacNotifier(log);
|
|
53
|
+
if (repaired) {
|
|
54
|
+
try {
|
|
55
|
+
await notifier.notify({ title, message, sound: settings.sound, ...macAppIconOption() });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
catch (retryError) {
|
|
59
|
+
const reason = describeNotifierError(retryError);
|
|
60
|
+
log(`(notify skipped after retry: ${reason})`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (isMacBadCpuError(error)) {
|
|
66
|
+
const reason = describeNotifierError(error);
|
|
67
|
+
log(`(notify skipped: ${reason})`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const reason = describeNotifierError(error);
|
|
71
|
+
log(`(notify skipped: ${reason})`);
|
|
72
|
+
}
|
|
73
|
+
// Last-resort macOS fallback: AppleScript alert (simple, noisy, but works when helpers are blocked).
|
|
74
|
+
if (process.platform === "darwin") {
|
|
75
|
+
try {
|
|
76
|
+
await sendOsascriptAlert(title, message, log);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
catch (scriptError) {
|
|
80
|
+
const reason = describeNotifierError(scriptError);
|
|
81
|
+
log(`(notify skipped: osascript fallback failed: ${reason})`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function buildMessage(payload, answerPreview) {
|
|
86
|
+
const parts = [];
|
|
87
|
+
const sessionLabel = payload.sessionName || payload.sessionId;
|
|
88
|
+
parts.push(sessionLabel);
|
|
89
|
+
// Show cost only for API runs.
|
|
90
|
+
if (payload.mode === "api") {
|
|
91
|
+
const cost = payload.costUsd ?? inferCost(payload);
|
|
92
|
+
if (cost !== undefined) {
|
|
93
|
+
// Round to $0.00 for a concise toast.
|
|
94
|
+
parts.push(formatUSD(Number(cost.toFixed(2))));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (payload.characters != null) {
|
|
98
|
+
parts.push(`${formatNumber(payload.characters)} chars`);
|
|
99
|
+
}
|
|
100
|
+
if (answerPreview) {
|
|
101
|
+
parts.push(answerPreview);
|
|
102
|
+
}
|
|
103
|
+
return parts.join(" · ");
|
|
104
|
+
}
|
|
105
|
+
function sanitizePreview(preview) {
|
|
106
|
+
if (!preview)
|
|
107
|
+
return undefined;
|
|
108
|
+
let text = preview;
|
|
109
|
+
// Strip code fences and inline code markers.
|
|
110
|
+
text = text.replace(/```[\s\S]*?```/g, " ");
|
|
111
|
+
text = text.replace(/`([^`]+)`/g, "$1");
|
|
112
|
+
// Convert markdown links and images to their visible text.
|
|
113
|
+
text = text.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1");
|
|
114
|
+
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
|
115
|
+
// Drop bold/italic markers.
|
|
116
|
+
text = text.replace(/(\*\*|__|\*|_)/g, "");
|
|
117
|
+
// Remove headings / list markers / blockquotes.
|
|
118
|
+
text = text.replace(/^\s*#+\s*/gm, "");
|
|
119
|
+
text = text.replace(/^\s*[-*+]\s+/gm, "");
|
|
120
|
+
text = text.replace(/^\s*>\s+/gm, "");
|
|
121
|
+
// Collapse whitespace and trim.
|
|
122
|
+
text = text.replace(/\s+/g, " ").trim();
|
|
123
|
+
// Limit length to keep notifications short.
|
|
124
|
+
const max = 200;
|
|
125
|
+
if (text.length > max) {
|
|
126
|
+
text = `${text.slice(0, max - 1)}…`;
|
|
127
|
+
}
|
|
128
|
+
return text;
|
|
129
|
+
}
|
|
130
|
+
// Exposed for unit tests only.
|
|
131
|
+
export const testHelpers = { sanitizePreview };
|
|
132
|
+
function inferCost(payload) {
|
|
133
|
+
const model = payload.model;
|
|
134
|
+
const usage = payload.usage;
|
|
135
|
+
if (!model || !usage)
|
|
136
|
+
return undefined;
|
|
137
|
+
const config = MODEL_CONFIGS[model];
|
|
138
|
+
if (!config?.pricing)
|
|
139
|
+
return undefined;
|
|
140
|
+
return (estimateUsdCost({
|
|
141
|
+
usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens },
|
|
142
|
+
pricing: {
|
|
143
|
+
inputUsdPerToken: config.pricing.inputPerToken,
|
|
144
|
+
outputUsdPerToken: config.pricing.outputPerToken,
|
|
145
|
+
},
|
|
146
|
+
})?.totalUsd ?? undefined);
|
|
147
|
+
}
|
|
148
|
+
function parseToggle(value) {
|
|
149
|
+
if (value == null)
|
|
150
|
+
return undefined;
|
|
151
|
+
const normalized = value.trim().toLowerCase();
|
|
152
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
153
|
+
return true;
|
|
154
|
+
if (["0", "false", "no", "off"].includes(normalized))
|
|
155
|
+
return false;
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
function bool(value) {
|
|
159
|
+
return Boolean(value && String(value).length > 0);
|
|
160
|
+
}
|
|
161
|
+
function isMacExecError(error) {
|
|
162
|
+
return Boolean(process.platform === "darwin" &&
|
|
163
|
+
error &&
|
|
164
|
+
typeof error === "object" &&
|
|
165
|
+
"code" in error &&
|
|
166
|
+
error.code === "EACCES");
|
|
167
|
+
}
|
|
168
|
+
function isMacBadCpuError(error) {
|
|
169
|
+
return Boolean(process.platform === "darwin" &&
|
|
170
|
+
error &&
|
|
171
|
+
typeof error === "object" &&
|
|
172
|
+
"errno" in error &&
|
|
173
|
+
error.errno === -86);
|
|
174
|
+
}
|
|
175
|
+
async function repairMacNotifier(log) {
|
|
176
|
+
const binPath = macNotifierPath();
|
|
177
|
+
if (!binPath)
|
|
178
|
+
return false;
|
|
179
|
+
try {
|
|
180
|
+
await fs.chmod(binPath, 0o755);
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
catch (chmodError) {
|
|
184
|
+
const reason = chmodError instanceof Error ? chmodError.message : String(chmodError);
|
|
185
|
+
log(`(notify repair failed: ${reason} — try: xattr -dr com.apple.quarantine "${path.dirname(binPath)}")`);
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function macNotifierPath() {
|
|
190
|
+
if (process.platform !== "darwin")
|
|
191
|
+
return null;
|
|
192
|
+
try {
|
|
193
|
+
const req = createRequire(import.meta.url);
|
|
194
|
+
const modPath = req.resolve("toasted-notifier");
|
|
195
|
+
const base = path.dirname(modPath);
|
|
196
|
+
return path.join(base, "vendor", "mac.noindex", "terminal-notifier.app", "Contents", "MacOS", "terminal-notifier");
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async function shouldSkipToastedNotifier() {
|
|
203
|
+
if (process.platform !== "darwin")
|
|
204
|
+
return false;
|
|
205
|
+
// On Apple Silicon without Rosetta, prefer the native helper and skip x86-only fallback.
|
|
206
|
+
const arch = process.arch;
|
|
207
|
+
if (arch !== "arm64")
|
|
208
|
+
return false;
|
|
209
|
+
return !(await hasRosetta());
|
|
210
|
+
}
|
|
211
|
+
async function hasRosetta() {
|
|
212
|
+
return new Promise((resolve) => {
|
|
213
|
+
const child = spawn("pkgutil", ["--files", "com.apple.pkg.RosettaUpdateAuto"], {
|
|
214
|
+
stdio: "ignore",
|
|
215
|
+
});
|
|
216
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
217
|
+
child.on("error", () => resolve(false));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
async function sendOsascriptAlert(title, message, _log) {
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
const child = spawn("osascript", [
|
|
223
|
+
"-e",
|
|
224
|
+
`display notification "${escapeAppleScript(message)}" with title "${escapeAppleScript(title)}"`,
|
|
225
|
+
], {
|
|
226
|
+
stdio: "ignore",
|
|
227
|
+
});
|
|
228
|
+
child.on("exit", (code) => {
|
|
229
|
+
if (code === 0) {
|
|
230
|
+
resolve();
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
reject(new Error(`osascript exited with code ${code ?? -1}`));
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
child.on("error", reject);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
function escapeAppleScript(value) {
|
|
240
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
241
|
+
}
|
|
242
|
+
function macAppIconOption() {
|
|
243
|
+
if (process.platform !== "darwin")
|
|
244
|
+
return {};
|
|
245
|
+
const iconPaths = [
|
|
246
|
+
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../assets-oracle-icon.png"),
|
|
247
|
+
path.resolve(process.cwd(), "assets-oracle-icon.png"),
|
|
248
|
+
];
|
|
249
|
+
for (const candidate of iconPaths) {
|
|
250
|
+
if (candidate && fsExistsSync(candidate)) {
|
|
251
|
+
return { appIcon: candidate };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return {};
|
|
255
|
+
}
|
|
256
|
+
function fsExistsSync(target) {
|
|
257
|
+
try {
|
|
258
|
+
return Boolean(require("node:fs").statSync(target));
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async function tryMacNativeNotifier(title, message, settings) {
|
|
265
|
+
const binary = macNativeNotifierPath();
|
|
266
|
+
if (!binary)
|
|
267
|
+
return false;
|
|
268
|
+
return new Promise((resolve) => {
|
|
269
|
+
const child = spawn(binary, [title, message, settings.sound ? "Glass" : ""], {
|
|
270
|
+
stdio: "ignore",
|
|
271
|
+
});
|
|
272
|
+
child.on("error", () => resolve(false));
|
|
273
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
function macNativeNotifierPath() {
|
|
277
|
+
if (process.platform !== "darwin")
|
|
278
|
+
return null;
|
|
279
|
+
const candidates = [
|
|
280
|
+
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier"),
|
|
281
|
+
path.resolve(process.cwd(), "vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier"),
|
|
282
|
+
];
|
|
283
|
+
for (const candidate of candidates) {
|
|
284
|
+
if (fsExistsSync(candidate)) {
|
|
285
|
+
return candidate;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
function muteByConfig(env, config) {
|
|
291
|
+
if (!config?.muteIn)
|
|
292
|
+
return false;
|
|
293
|
+
return ((config.muteIn.includes("CI") && bool(env.CI)) ||
|
|
294
|
+
(config.muteIn.includes("SSH") && bool(env.SSH_CONNECTION)));
|
|
295
|
+
}
|
|
296
|
+
function isTestEnv(env) {
|
|
297
|
+
return (env.ORACLE_DISABLE_NOTIFICATIONS === "1" ||
|
|
298
|
+
env.NODE_ENV === "test" ||
|
|
299
|
+
Boolean(env.VITEST || env.VITEST_WORKER_ID || env.JEST_WORKER_ID));
|
|
300
|
+
}
|
|
301
|
+
function describeNotifierError(error) {
|
|
302
|
+
if (error && typeof error === "object") {
|
|
303
|
+
const err = error;
|
|
304
|
+
if (typeof err.errno === "number" || typeof err.code === "string") {
|
|
305
|
+
const errno = typeof err.errno === "number" ? err.errno : undefined;
|
|
306
|
+
// macOS returns errno -86 for “Bad CPU type in executable” (e.g., wrong arch or quarantined binary).
|
|
307
|
+
if (errno === -86) {
|
|
308
|
+
return "notifier binary failed to launch (Bad CPU type/quarantine); try xattr -dr com.apple.quarantine vendor/oracle-notifier && ./vendor/oracle-notifier/build-notifier.sh";
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (typeof err.message === "string") {
|
|
312
|
+
return err.message;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return typeof error === "string" ? error : String(error);
|
|
316
|
+
}
|