Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
},
"dependencies": {
"axios": "^1.13.6",
"electron-store": "^11.0.2"
"electron-store": "^11.0.2",
"groq-sdk": "^1.1.2"
}
}
25 changes: 25 additions & 0 deletions riotgames.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-----BEGIN CERTIFICATE-----
MIIEIDCCAwgCCQDJC+QAdVx4UDANBgkqhkiG9w0BAQUFADCB0TELMAkGA1UEBhMC
VVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFTATBgNVBAcTDFNhbnRhIE1vbmljYTET
MBEGA1UEChMKUmlvdCBHYW1lczEdMBsGA1UECxMUTG9MIEdhbWUgRW5naW5lZXJp
bmcxMzAxBgNVBAMTKkxvTCBHYW1lIEVuZ2luZWVyaW5nIENlcnRpZmljYXRlIEF1
dGhvcml0eTEtMCsGCSqGSIb3DQEJARYeZ2FtZXRlY2hub2xvZ2llc0ByaW90Z2Ft
ZXMuY29tMB4XDTEzMTIwNDAwNDgzOVoXDTQzMTEyNzAwNDgzOVowgdExCzAJBgNV
BAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRUwEwYDVQQHEwxTYW50YSBNb25p
Y2ExEzARBgNVBAoTClJpb3QgR2FtZXMxHTAbBgNVBAsTFExvTCBHYW1lIEVuZ2lu
ZWVyaW5nMTMwMQYDVQQDEypMb0wgR2FtZSBFbmdpbmVlcmluZyBDZXJ0aWZpY2F0
ZSBBdXRob3JpdHkxLTArBgkqhkiG9w0BCQEWHmdhbWV0ZWNobm9sb2dpZXNAcmlv
dGdhbWVzLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKoJemF/
6PNG3GRJGbjzImTdOo1OJRDI7noRwJgDqkaJFkwv0X8aPUGbZSUzUO23cQcCgpYj
21ygzKu5dtCN2EcQVVpNtyPuM2V4eEGr1woodzALtufL3Nlyh6g5jKKuDIfeUBHv
JNyQf2h3Uha16lnrXmz9o9wsX/jf+jUAljBJqsMeACOpXfuZy+YKUCxSPOZaYTLC
y+0GQfiT431pJHBQlrXAUwzOmaJPQ7M6mLfsnpHibSkxUfMfHROaYCZ/sbWKl3lr
ZA9DbwaKKfS1Iw0ucAeDudyuqb4JntGU/W0aboKA0c3YB02mxAM4oDnqseuKV/CX
8SQAiaXnYotuNXMCAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAf3KPmddqEqqC8iLs
lcd0euC4F5+USp9YsrZ3WuOzHqVxTtX3hR1scdlDXNvrsebQZUqwGdZGMS16ln3k
WObw7BbhU89tDNCN7Lt/IjT4MGRYRE+TmRc5EeIXxHkQ78bQqbmAI3GsW+7kJsoO
q3DdeE+M+BUJrhWorsAQCgUyZO166SAtKXKLIcxa+ddC49NvMQPJyzm3V+2b1roP
SvD2WV8gRYUnGmy/N0+u6ANq5EsbhZ548zZc+BI4upsWChTLyxt2RxR7+uGlS1+5
EcGfKZ+g024k/J32XP4hdho7WYAS2xMiV83CfLR/MNi8oSMaVQTdKD8cpgiWJk3L
XWehWA==
-----END CERTIFICATE-----
120 changes: 120 additions & 0 deletions src/main/coach.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Groq from 'groq-sdk';
import type { GameSnapshot } from '../riot.types.js';
import type { GamePhase } from './phases.js';

const GROQ_TIMEOUT_MS = 2500;
const MAX_WORDS = 15;

let groq: Groq | null = null;
let apiKey: string | null = null;

const recentPhrases: string[] = [];
const MAX_RECENT = 6;

export function setGroqApiKey(key: string): void {
apiKey = key;
groq = null;
}

export function clearGroqApiKey(): void {
apiKey = null;
groq = null;
}

export function resetCoachHistory(): void {
recentPhrases.length = 0;
}

function getClient(): Groq | null {
if (groq) return groq;
if (!apiKey) return null;
groq = new Groq({ apiKey });
return groq;
}

function getPlayerContext(snapshot: GameSnapshot): string {
const { activePlayer, allPlayers } = snapshot;
const me = allPlayers.find((p) => p.riotId === activePlayer.riotId);
if (!me) return '';

const enemyLaner = allPlayers.find(
(p) => p.team !== me.team && p.position === me.position,
);

const parts = [
`Champion: ${me.championName}`,
`Role: ${me.position}`,
`Level: ${activePlayer.level}`,
`KDA: ${me.scores.kills}/${me.scores.deaths}/${me.scores.assists}`,
];

if (enemyLaner) {
parts.push(`Lane opponent: ${enemyLaner.championName}`);
}

return parts.join(', ');
}

const SYSTEM_PROMPT = `Rephrase a League of Legends coaching reminder. Output ONLY the rephrased line.

RULES:
- 1 short sentence. Max 15 words.
- Same meaning. Do NOT add advice, strategy, or facts.
- Use the champion name instead of "you".
- Sound like a coach on comms: punchy, clipped, zero filler.
- No quotes, no preamble, no explanation.`;

export async function rephrasePrompt(
basePrompt: string,
snapshot: GameSnapshot,
phase: GamePhase,
): Promise<string | null> {
const client = getClient();
if (!client) return null;

const playerContext = getPlayerContext(snapshot);
const minutes = Math.floor(snapshot.gameData.gameTime / 60);
const seconds = String(Math.floor(snapshot.gameData.gameTime % 60)).padStart(
2,
'0',
);

const recentBlock =
recentPhrases.length > 0
? `\nRecent callouts (phrase it differently from these):\n${recentPhrases.map((p) => `- "${p}"`).join('\n')}`
: '';

try {
const response = await client.chat.completions.create(
{
model: 'llama-3.3-70b-versatile',
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{
role: 'user',
content: `Game time: ${minutes}:${seconds} (${phase.replace('_', ' ')})\n${playerContext}\n\nRephrase this: "${basePrompt}"${recentBlock}`,
},
],
max_tokens: 35,
temperature: 0.7,
},
{ timeout: GROQ_TIMEOUT_MS },
);

const text = response.choices[0]?.message?.content?.trim() ?? null;
if (!text) return null;

// Reject if output exceeds word limit or contains multiple sentences
const wordCount = text.split(/\s+/).length;
const hasMultipleSentences = /[.!?]\s/.test(text);
if (wordCount > MAX_WORDS || hasMultipleSentences) return null;

recentPhrases.unshift(text);
if (recentPhrases.length > MAX_RECENT) {
recentPhrases.pop();
}
return text;
Comment on lines +104 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Enforce output constraints before returning model text.

Model output is trusted as-is. If it violates the 1-sentence/15-word constraint or adds extra content, it still gets surfaced to users. Validate locally and fall back to null on invalid output.

Proposed fix
 const MAX_RECENT = 6;
+const MAX_WORDS = 15;
+
+function isValidRephrase(text: string): boolean {
+  const wordCount = text.trim().split(/\s+/).filter(Boolean).length;
+  const sentenceCount = text.split(/[.!?]+/).filter((s) => s.trim().length > 0).length;
+  return wordCount > 0 && wordCount <= MAX_WORDS && sentenceCount <= 1;
+}
@@
-    const text = response.choices[0]?.message?.content?.trim() ?? null;
-    if (text) {
+    const text = response.choices[0]?.message?.content?.trim() ?? null;
+    if (!text || !isValidRephrase(text)) return null;
+    {
       recentPhrases.unshift(text);
       if (recentPhrases.length > MAX_RECENT) {
         recentPhrases.pop();
       }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const text = response.choices[0]?.message?.content?.trim() ?? null;
if (text) {
recentPhrases.unshift(text);
if (recentPhrases.length > MAX_RECENT) {
recentPhrases.pop();
}
}
return text;
const MAX_RECENT = 6;
const MAX_WORDS = 15;
function isValidRephrase(text: string): boolean {
const wordCount = text.trim().split(/\s+/).filter(Boolean).length;
const sentenceCount = text.split(/[.!?]+/).filter((s) => s.trim().length > 0).length;
return wordCount > 0 && wordCount <= MAX_WORDS && sentenceCount <= 1;
}
const text = response.choices[0]?.message?.content?.trim() ?? null;
if (!text || !isValidRephrase(text)) return null;
{
recentPhrases.unshift(text);
if (recentPhrases.length > MAX_RECENT) {
recentPhrases.pop();
}
}
return text;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/coach.ts` around lines 93 - 100, Validate the model output in the
block that reads response.choices[0]?.message?.content before adding to
recentPhrases: compute text by trimming, then reject (set text to null and do
not push to recentPhrases) if it contains more than one sentence (e.g., more
than one sentence-ending punctuation like '.', '!', '?' sequence) or more than
15 words (split on whitespace to count words); only when it passes both checks
should you unshift into recentPhrases and enforce MAX_RECENT; finally return the
validated text (or null on failure).

} catch {
return null;
}
}
11 changes: 5 additions & 6 deletions src/main/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,14 +384,14 @@ describe('deriveContext', () => {
const snap = makeSnapshot({ level: 4 });
const state = makeState({ lastKnownLevel: 3 });
const { result } = deriveContext(snap, state);
expect(result?.reason).not.toMatch(/^level_/);
expect(result?.reason ?? '').not.toMatch(/^level_/);
});

it('does not fire trading when level unchanged', () => {
const snap = makeSnapshot({ level: 6 });
const state = makeState({ lastKnownLevel: 6 });
const { result } = deriveContext(snap, state);
expect(result?.reason).not.toMatch(/^level_/);
expect(result?.reason ?? '').not.toMatch(/^level_/);
});

// --- Signal 10: Vision periodic ---
Expand All @@ -414,12 +414,11 @@ describe('deriveContext', () => {
expect(newState.lastTabCheckAt).toBe(300);
});

// --- Signal 12: Fallback ---
it('returns map_awareness as fallback', () => {
// --- Signal 12: No detectors fire ---
it('returns null when no detector fires', () => {
const snap = makeSnapshot();
const { result } = deriveContext(snap, makeState());
expect(result?.category).toBe('map_awareness');
expect(result?.reason).toBe('fallback');
expect(result).toBeNull();
});

// --- Priority ---
Expand Down
9 changes: 1 addition & 8 deletions src/main/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,7 @@ export function deriveContext(
}

if (results.length === 0) {
return {
result: {
category: 'map_awareness' as PromptCategory,
reason: 'fallback',
data: {},
},
newState,
};
return { result: null, newState };
}

results.sort((a, b) => b.priority - a.priority);
Expand Down
7 changes: 1 addition & 6 deletions src/main/detectors.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import type {
ActivePlayer,
GameEvent,
GameSnapshot,
Player,
} from '../riot.types.js';
import type { GameEvent, GameSnapshot, Player } from '../riot.types.js';
import type { PromptCategory } from '../types.js';
import type { ContextState } from './context.js';
import {
Expand Down
54 changes: 51 additions & 3 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import {
BrowserWindow,
globalShortcut,
ipcMain,
safeStorage,
screen,
utilityProcess,
} from 'electron';
import Store from 'electron-store';
import { setGroqApiKey, clearGroqApiKey } from './coach.js';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Position } from '../types.js';
import type { AppStore } from '../types.js';
import {
cycleOutputMode,
handleServerMessage,
Expand All @@ -21,7 +23,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const preloadPath = path.join(rootDir, 'dist', 'preload', 'preload.js');

const store = new Store<Position>({
const store = new Store<AppStore>({
defaults: {
x: 0,
y: 0,
Expand All @@ -31,7 +33,7 @@ const store = new Store<Position>({
const createHubWindow = () => {
const win = new BrowserWindow({
width: 380,
height: 320,
height: 420,
frame: false,
resizable: false,
transparent: true,
Expand Down Expand Up @@ -117,6 +119,52 @@ app.whenReady().then(() => {

ipcMain.handle('get-version', () => app.getVersion());

ipcMain.handle('has-api-key', () => {
const encrypted = store.get('groqApiKey') as string | undefined;
if (!encrypted) return false;
try {
if (!safeStorage.isEncryptionAvailable()) return false;
safeStorage.decryptString(Buffer.from(encrypted, 'base64'));
return true;
} catch {
return false;
}
});

ipcMain.handle('set-api-key', (_event, key: string) => {
if (key) {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error('Encryption unavailable on this system');
}
try {
const encrypted = safeStorage.encryptString(key).toString('base64');
store.set('groqApiKey', encrypted);
setGroqApiKey(key);
} catch {
throw new Error('Failed to encrypt API key');
}
} else {
store.delete('groqApiKey');
clearGroqApiKey();
}
});
Comment on lines +134 to +150

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clearing the stored key does not clear the active key in memory.

Line 131 deletes from storage, but the previously set runtime key remains active because setGroqApiKey is not called in the empty-key branch. Clear the runtime key in the else branch.

Proposed fix
   ipcMain.handle('set-api-key', (_event, key: string) => {
     if (key) {
       const encrypted = safeStorage.encryptString(key).toString('base64');
       store.set('groqApiKey', encrypted);
       setGroqApiKey(key);
     } else {
       store.delete('groqApiKey');
+      setGroqApiKey('');
     }
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ipcMain.handle('set-api-key', (_event, key: string) => {
if (key) {
const encrypted = safeStorage.encryptString(key).toString('base64');
store.set('groqApiKey', encrypted);
setGroqApiKey(key);
} else {
store.delete('groqApiKey');
}
});
ipcMain.handle('set-api-key', (_event, key: string) => {
if (key) {
const encrypted = safeStorage.encryptString(key).toString('base64');
store.set('groqApiKey', encrypted);
setGroqApiKey(key);
} else {
store.delete('groqApiKey');
setGroqApiKey('');
}
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/main.ts` around lines 125 - 133, The else branch that deletes the
stored key via store.delete('groqApiKey') doesn't clear the in-memory/active API
key; update the ipcMain.handle('set-api-key', ...) handler so that when key is
falsy you also call setGroqApiKey with an empty value (e.g., setGroqApiKey('')
or null per its contract) after store.delete('groqApiKey') to clear the runtime
key; keep the existing encrypted/write path unchanged.


// Load saved key on startup
const savedKey = store.get('groqApiKey') as string | undefined;
if (savedKey) {
try {
if (safeStorage.isEncryptionAvailable()) {
const decrypted = safeStorage.decryptString(
Buffer.from(savedKey, 'base64'),
);
setGroqApiKey(decrypted);
}
} catch {
// corrupted or unreadable key — remove it so the user can re-enter
store.delete('groqApiKey');
}
}

ipcMain.on('set-position', (_event, pos: { dx: number; dy: number }) => {
const [currentX, currentY] = overlay.getPosition() as [number, number];
overlay.setPosition(currentX + pos.dx, currentY + pos.dy);
Expand Down
Loading