spotme 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 +131 -0
- package/dist/index.js +13024 -0
- package/dist/opencode.js +13024 -0
- package/dist/pi.js +258275 -0
- package/package.json +65 -0
- package/src/engine.ts +162 -0
- package/src/index.ts +2 -0
- package/src/opencode.ts +156 -0
- package/src/pi.ts +141 -0
- package/src/prompts.ts +152 -0
- package/src/types.ts +41 -0
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spotme",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SpotMe — gym mode for agentic coding. Works with OpenCode and Pi.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "wtfzambo"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./dist/index.js",
|
|
11
|
+
"./opencode": "./dist/opencode.js",
|
|
12
|
+
"./pi": "./dist/pi.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/wtfzambo/spotme" },
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "bun build src/index.ts src/opencode.ts src/pi.ts --outdir dist --target bun",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"lint": "eslint src",
|
|
28
|
+
"lint:fix": "eslint src --fix",
|
|
29
|
+
"format": "prettier --write src"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"pi-package",
|
|
33
|
+
"opencode-plugin",
|
|
34
|
+
"spotme",
|
|
35
|
+
"gym-mode",
|
|
36
|
+
"coding-agent"
|
|
37
|
+
],
|
|
38
|
+
"pi": {
|
|
39
|
+
"extensions": [
|
|
40
|
+
"./src/pi.ts"
|
|
41
|
+
],
|
|
42
|
+
"skills": [
|
|
43
|
+
"."
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@earendil-works/pi-coding-agent": "latest",
|
|
48
|
+
"@opencode-ai/plugin": "^1.14.0",
|
|
49
|
+
"@sinclair/typebox": "^0.34.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@eslint/js": "^9.39.1",
|
|
53
|
+
"@types/bun": "^1.1.0",
|
|
54
|
+
"@types/node": "^22.0.0",
|
|
55
|
+
"@typescript-eslint/eslint-plugin": "8.47.0",
|
|
56
|
+
"@typescript-eslint/parser": "8.47.0",
|
|
57
|
+
"backlog.md": "^1.45.1",
|
|
58
|
+
"eslint": "^9.39.1",
|
|
59
|
+
"eslint-config-prettier": "10.1.8",
|
|
60
|
+
"eslint-plugin-prettier": "^5.1.3",
|
|
61
|
+
"prettier": "^3.2.4",
|
|
62
|
+
"typescript": "^5.7.0",
|
|
63
|
+
"typescript-eslint": "^8.47.0"
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// ─── SpotMe Engine ─────────────────────────────────────────────────────────
|
|
2
|
+
// Platform-agnostic core logic. Adapters (OpenCode, Pi, etc.) delegate here.
|
|
3
|
+
|
|
4
|
+
import { blockedMessage, exerciseReadyMessage, statusMessage } from './prompts.js';
|
|
5
|
+
import type { Difficulty, SpotMeState } from './types.js';
|
|
6
|
+
import { CODE_WRITE_TOOLS, parseArgs } from './types.js';
|
|
7
|
+
|
|
8
|
+
// ─── Platform adapter interface ─────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface PlatformAdapter {
|
|
11
|
+
/** Resolve a raw file path (possibly absolute) to { fullPath, relativePath }. */
|
|
12
|
+
resolvePath(_rawPath: string): { fullPath: string; relativePath: string };
|
|
13
|
+
|
|
14
|
+
/** Return true if the file at `fullPath` exists on disk. */
|
|
15
|
+
fileExists(_fullPath: string): Promise<boolean>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ─── Return types for engine methods ────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface ActivateResult {
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ExerciseResult {
|
|
25
|
+
message: string;
|
|
26
|
+
filePath: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type WriteInterceptResult = { blocked: false } | { blocked: true; message: string };
|
|
30
|
+
|
|
31
|
+
// ─── Engine ─────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export class SpotMeEngine {
|
|
34
|
+
readonly state: SpotMeState;
|
|
35
|
+
private exercisePending = false;
|
|
36
|
+
private readonly platform: PlatformAdapter;
|
|
37
|
+
|
|
38
|
+
constructor(platform: PlatformAdapter) {
|
|
39
|
+
this.platform = platform;
|
|
40
|
+
this.state = {
|
|
41
|
+
enabled: false,
|
|
42
|
+
difficulty: 'medium',
|
|
43
|
+
every: 2,
|
|
44
|
+
counter: 0,
|
|
45
|
+
exercise: null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Activate SpotMe with the given (or current) settings.
|
|
53
|
+
* Called by the /spotme:on command hook or the spotme_on tool.
|
|
54
|
+
*/
|
|
55
|
+
activate(args?: { difficulty?: Difficulty; every?: number }): ActivateResult {
|
|
56
|
+
if (args?.difficulty) this.state.difficulty = args.difficulty;
|
|
57
|
+
if (args?.every) this.state.every = Math.max(1, Math.floor(args.every));
|
|
58
|
+
this.state.enabled = true;
|
|
59
|
+
this.state.counter = 0;
|
|
60
|
+
this.state.exercise = null;
|
|
61
|
+
this.exercisePending = false;
|
|
62
|
+
return {
|
|
63
|
+
message: `🏋️ SpotMe is on. Difficulty: ${this.state.difficulty}. Triggering every ${this.state.every} code write(s).`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Activate from raw CLI args string (e.g. "hard --every 3").
|
|
69
|
+
* Parses args using current state as defaults.
|
|
70
|
+
*/
|
|
71
|
+
activateFromArgs(rawArgs: string): ActivateResult {
|
|
72
|
+
const parsed = parseArgs(rawArgs, this.state);
|
|
73
|
+
return this.activate(parsed);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Deactivate SpotMe entirely. */
|
|
77
|
+
deactivate(): void {
|
|
78
|
+
this.state.enabled = false;
|
|
79
|
+
this.state.exercise = null;
|
|
80
|
+
this.state.counter = 0;
|
|
81
|
+
this.exercisePending = false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Exercise lifecycle ──────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Record an exercise start. Called by the spotme_exercise tool AFTER the
|
|
88
|
+
* scaffold file has been written.
|
|
89
|
+
*/
|
|
90
|
+
async recordExercise(
|
|
91
|
+
unit: string,
|
|
92
|
+
rawFilePath: string,
|
|
93
|
+
difficulty: Difficulty
|
|
94
|
+
): Promise<ExerciseResult> {
|
|
95
|
+
const { fullPath, relativePath } = this.platform.resolvePath(rawFilePath);
|
|
96
|
+
|
|
97
|
+
if (!(await this.platform.fileExists(fullPath))) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Scaffold file not found at ${fullPath}. Write the scaffold with the Write tool first, then call spotme_exercise.`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.exercisePending = false;
|
|
104
|
+
this.state.exercise = {
|
|
105
|
+
active: true,
|
|
106
|
+
unit,
|
|
107
|
+
filePath: relativePath,
|
|
108
|
+
difficulty,
|
|
109
|
+
};
|
|
110
|
+
this.state.counter = 0;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
message: exerciseReadyMessage(unit, relativePath, difficulty),
|
|
114
|
+
filePath: relativePath,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Close the current exercise. Called by spotme_end tool. */
|
|
119
|
+
endExercise(): string {
|
|
120
|
+
this.state.exercise = null;
|
|
121
|
+
this.state.counter = 0;
|
|
122
|
+
this.exercisePending = false;
|
|
123
|
+
return '✅ Exercise closed. Counter reset. Resuming normal mode.';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Status ──────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
getStatus(): string {
|
|
129
|
+
return statusMessage(this.state);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Write interception ────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Called before every tool execution. Returns whether the write should be
|
|
136
|
+
* blocked (exercise trigger) or allowed.
|
|
137
|
+
*
|
|
138
|
+
* @param toolName - Name of the tool being executed.
|
|
139
|
+
* @param filePath - File path argument from the tool call (may be empty).
|
|
140
|
+
*/
|
|
141
|
+
interceptWriteToolCall(toolName: string, filePath: string): WriteInterceptResult {
|
|
142
|
+
if (!this.state.enabled) return { blocked: false };
|
|
143
|
+
|
|
144
|
+
// Bypass: scaffold write in progress, or exercise active (user implementing).
|
|
145
|
+
if (this.exercisePending || this.state.exercise?.active) return { blocked: false };
|
|
146
|
+
|
|
147
|
+
// Only count code-writing tools.
|
|
148
|
+
if (!CODE_WRITE_TOOLS.has(toolName)) return { blocked: false };
|
|
149
|
+
|
|
150
|
+
this.state.counter++;
|
|
151
|
+
if (this.state.counter >= this.state.every) {
|
|
152
|
+
this.state.counter = 0;
|
|
153
|
+
this.exercisePending = true;
|
|
154
|
+
return {
|
|
155
|
+
blocked: true,
|
|
156
|
+
message: blockedMessage(toolName, filePath, this.state.difficulty),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { blocked: false };
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/index.ts
ADDED
package/src/opencode.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// ─── OpenCode adapter ───────────────────────────────────────────────────────
|
|
2
|
+
// Thin wiring layer: connects SpotMeEngine to OpenCode's plugin API.
|
|
3
|
+
|
|
4
|
+
import { type Plugin, tool } from '@opencode-ai/plugin';
|
|
5
|
+
import { SpotMeEngine } from './engine.js';
|
|
6
|
+
import { PROMPTS } from './prompts.js';
|
|
7
|
+
import type { Difficulty } from './types.js';
|
|
8
|
+
|
|
9
|
+
export const SpotMePlugin: Plugin = async ({ directory, client }) => {
|
|
10
|
+
const engine = new SpotMeEngine({
|
|
11
|
+
resolvePath(rawPath) {
|
|
12
|
+
const fullPath = rawPath.startsWith('/') ? rawPath : `${directory}/${rawPath}`;
|
|
13
|
+
const relativePath = fullPath.startsWith(`${directory}/`)
|
|
14
|
+
? fullPath.slice(directory.length + 1)
|
|
15
|
+
: rawPath;
|
|
16
|
+
return { fullPath, relativePath };
|
|
17
|
+
},
|
|
18
|
+
async fileExists(fullPath) {
|
|
19
|
+
return Bun.file(fullPath).exists();
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ─── Tools ──────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const spotme_on = tool({
|
|
26
|
+
description: 'Activate SpotMe gym mode with the specified difficulty and frequency.',
|
|
27
|
+
args: {
|
|
28
|
+
difficulty: tool.schema
|
|
29
|
+
.enum(['lite', 'medium', 'hard'])
|
|
30
|
+
.default('medium')
|
|
31
|
+
.describe('Exercise difficulty (default: medium)'),
|
|
32
|
+
every: tool.schema
|
|
33
|
+
.number()
|
|
34
|
+
.default(2)
|
|
35
|
+
.describe('How many code writes before triggering an exercise (default: 2)'),
|
|
36
|
+
},
|
|
37
|
+
async execute(args) {
|
|
38
|
+
const result = engine.activate({
|
|
39
|
+
difficulty: args.difficulty as Difficulty,
|
|
40
|
+
every: args.every,
|
|
41
|
+
});
|
|
42
|
+
return result.message;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const spotme_exercise = tool({
|
|
47
|
+
description:
|
|
48
|
+
'Record the start of a SpotMe coding exercise. Call this AFTER writing the scaffold with the Write tool.',
|
|
49
|
+
args: {
|
|
50
|
+
unit: tool.schema
|
|
51
|
+
.string()
|
|
52
|
+
.describe("Name of the unit being exercised (e.g. 'UserAuth.login')"),
|
|
53
|
+
filePath: tool.schema
|
|
54
|
+
.string()
|
|
55
|
+
.describe('Path to the scaffold file (already written to disk)'),
|
|
56
|
+
difficulty: tool.schema
|
|
57
|
+
.enum(['lite', 'medium', 'hard'])
|
|
58
|
+
.describe('Difficulty — must match the active session setting'),
|
|
59
|
+
},
|
|
60
|
+
async execute(args) {
|
|
61
|
+
const result = await engine.recordExercise(
|
|
62
|
+
args.unit,
|
|
63
|
+
args.filePath,
|
|
64
|
+
args.difficulty as Difficulty
|
|
65
|
+
);
|
|
66
|
+
return result.message;
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const spotme_status = tool({
|
|
71
|
+
description: 'Show the current SpotMe session status.',
|
|
72
|
+
args: {},
|
|
73
|
+
async execute() {
|
|
74
|
+
return engine.getStatus();
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const spotme_end = tool({
|
|
79
|
+
description:
|
|
80
|
+
'Close the current SpotMe exercise. Call this after reviewing, solving, or skipping.',
|
|
81
|
+
args: {},
|
|
82
|
+
async execute() {
|
|
83
|
+
return engine.endExercise();
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ─── Commands ─────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
const commands: Record<string, { description: string; template: string }> = {
|
|
90
|
+
'spotme:on': {
|
|
91
|
+
description: 'Enable SpotMe gym mode [lite|medium|hard] [--every N]',
|
|
92
|
+
template: PROMPTS.ON,
|
|
93
|
+
},
|
|
94
|
+
'spotme:off': { description: 'Disable SpotMe gym mode', template: PROMPTS.OFF },
|
|
95
|
+
'spotme:status': { description: 'Show current SpotMe status', template: PROMPTS.STATUS },
|
|
96
|
+
'spotme:done': {
|
|
97
|
+
description: 'Submit your implementation for SpotMe review',
|
|
98
|
+
template: PROMPTS.DONE,
|
|
99
|
+
},
|
|
100
|
+
'spotme:hint': {
|
|
101
|
+
description: 'Get a hint for the current exercise',
|
|
102
|
+
template: PROMPTS.HINT,
|
|
103
|
+
},
|
|
104
|
+
'spotme:solve': {
|
|
105
|
+
description: 'Concede — let the agent finish the exercise',
|
|
106
|
+
template: PROMPTS.SOLVE,
|
|
107
|
+
},
|
|
108
|
+
'spotme:skip': { description: 'Skip this exercise', template: PROMPTS.SKIP },
|
|
109
|
+
'spotme:rep': {
|
|
110
|
+
description: 'Request an on-demand SpotMe exercise',
|
|
111
|
+
template: PROMPTS.REP,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// ─── Hooks ────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
config: async (cfg) => {
|
|
119
|
+
cfg.command = cfg.command ?? {};
|
|
120
|
+
for (const [name, def] of Object.entries(commands)) {
|
|
121
|
+
cfg.command[name] = { template: def.template, description: def.description };
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
tool: { spotme_on, spotme_exercise, spotme_status, spotme_end },
|
|
126
|
+
|
|
127
|
+
'tool.execute.before': async (input, output) => {
|
|
128
|
+
const filePath = (output.args?.filePath ?? output.args?.path ?? '') as string;
|
|
129
|
+
const result = engine.interceptWriteToolCall(input.tool, filePath);
|
|
130
|
+
if (result.blocked) throw new Error(result.message);
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
'command.execute.before': async (input) => {
|
|
134
|
+
const { command, arguments: rawArgs } = input;
|
|
135
|
+
|
|
136
|
+
if (command === 'spotme:on') {
|
|
137
|
+
engine.activateFromArgs(rawArgs);
|
|
138
|
+
await client.tui.showToast({
|
|
139
|
+
body: {
|
|
140
|
+
title: 'SpotMe',
|
|
141
|
+
message: `🏋️ On — ${engine.state.difficulty}, every ${engine.state.every} write(s)`,
|
|
142
|
+
variant: 'success',
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (command === 'spotme:off') {
|
|
149
|
+
engine.deactivate();
|
|
150
|
+
await client.tui.showToast({
|
|
151
|
+
body: { title: 'SpotMe', message: '⏹️ Off — normal coding resumed', variant: 'info' },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
};
|
package/src/pi.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// ─── Pi adapter ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Thin wiring layer: connects SpotMeEngine to Pi's extension API.
|
|
3
|
+
|
|
4
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { isToolCallEventType } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import { Type } from '@sinclair/typebox';
|
|
7
|
+
import { access } from 'fs/promises';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { SpotMeEngine } from './engine.js';
|
|
10
|
+
import { PROMPTS } from './prompts.js';
|
|
11
|
+
import type { Difficulty } from './types.js';
|
|
12
|
+
import { CODE_WRITE_TOOLS } from './types.js';
|
|
13
|
+
|
|
14
|
+
export default function (pi: ExtensionAPI) {
|
|
15
|
+
// Engine is instantiated lazily per-command since Pi doesn't provide cwd at init.
|
|
16
|
+
// We create it once and use ctx.cwd at tool-execution time.
|
|
17
|
+
let engineCwd: string | null = null;
|
|
18
|
+
|
|
19
|
+
const engine = new SpotMeEngine({
|
|
20
|
+
resolvePath(rawPath) {
|
|
21
|
+
const cwd = engineCwd ?? process.cwd();
|
|
22
|
+
const fullPath = rawPath.startsWith('/') ? rawPath : join(cwd, rawPath);
|
|
23
|
+
const relativePath = fullPath.startsWith(cwd + '/')
|
|
24
|
+
? fullPath.slice(cwd.length + 1)
|
|
25
|
+
: rawPath;
|
|
26
|
+
return { fullPath, relativePath };
|
|
27
|
+
},
|
|
28
|
+
async fileExists(fullPath) {
|
|
29
|
+
try {
|
|
30
|
+
await access(fullPath);
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ─── Tool: spotme_exercise ─────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
pi.registerTool({
|
|
41
|
+
name: 'spotme_exercise',
|
|
42
|
+
label: 'SpotMe Exercise',
|
|
43
|
+
description:
|
|
44
|
+
'Record the start of a SpotMe coding exercise. Call this AFTER writing the scaffold with the Write tool.',
|
|
45
|
+
parameters: Type.Object({
|
|
46
|
+
unit: Type.String({ description: 'Name of the unit being exercised' }),
|
|
47
|
+
filePath: Type.String({ description: 'Path to the scaffold file (already written to disk)' }),
|
|
48
|
+
difficulty: Type.Union([Type.Literal('lite'), Type.Literal('medium'), Type.Literal('hard')], {
|
|
49
|
+
description: 'Difficulty — must match the active session setting',
|
|
50
|
+
}),
|
|
51
|
+
}),
|
|
52
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
53
|
+
engineCwd = ctx.cwd ?? process.cwd();
|
|
54
|
+
const result = await engine.recordExercise(
|
|
55
|
+
params.unit,
|
|
56
|
+
params.filePath,
|
|
57
|
+
params.difficulty as Difficulty
|
|
58
|
+
);
|
|
59
|
+
return { content: [{ type: 'text' as const, text: result.message }], details: {} };
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ─── Commands ─────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
pi.registerCommand('spotme:on', {
|
|
66
|
+
description: 'Enable SpotMe gym mode [lite|medium|hard] [--every N]',
|
|
67
|
+
handler: async (args) => {
|
|
68
|
+
engine.activateFromArgs(args ?? '');
|
|
69
|
+
pi.sendUserMessage(PROMPTS.ON);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
pi.registerCommand('spotme:off', {
|
|
74
|
+
description: 'Disable SpotMe gym mode',
|
|
75
|
+
handler: async () => {
|
|
76
|
+
engine.deactivate();
|
|
77
|
+
pi.sendUserMessage(PROMPTS.OFF);
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
pi.registerCommand('spotme:status', {
|
|
82
|
+
description: 'Show current SpotMe status',
|
|
83
|
+
handler: async (_args, ctx) => {
|
|
84
|
+
ctx.ui.notify(engine.getStatus(), 'info');
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
pi.registerCommand('spotme:done', {
|
|
89
|
+
description: 'Submit your implementation for review',
|
|
90
|
+
handler: async () => {
|
|
91
|
+
engine.endExercise();
|
|
92
|
+
pi.sendUserMessage(PROMPTS.DONE);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
pi.registerCommand('spotme:hint', {
|
|
97
|
+
description: 'Get a targeted hint for the current exercise',
|
|
98
|
+
handler: async () => {
|
|
99
|
+
pi.sendUserMessage(PROMPTS.HINT);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
pi.registerCommand('spotme:solve', {
|
|
104
|
+
description: 'Concede — let the agent complete the exercise',
|
|
105
|
+
handler: async () => {
|
|
106
|
+
engine.endExercise();
|
|
107
|
+
pi.sendUserMessage(PROMPTS.SOLVE);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
pi.registerCommand('spotme:skip', {
|
|
112
|
+
description: 'Skip this exercise with no penalty',
|
|
113
|
+
handler: async () => {
|
|
114
|
+
engine.endExercise();
|
|
115
|
+
pi.sendUserMessage(PROMPTS.SKIP);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
pi.registerCommand('spotme:rep', {
|
|
120
|
+
description: 'Request an on-demand SpotMe exercise',
|
|
121
|
+
handler: async () => {
|
|
122
|
+
pi.sendUserMessage(PROMPTS.REP);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ─── Hook: intercept code-writing tool calls ──────────────────────────
|
|
127
|
+
|
|
128
|
+
pi.on('tool_call', async (event) => {
|
|
129
|
+
if (!CODE_WRITE_TOOLS.has(event.toolName)) return;
|
|
130
|
+
|
|
131
|
+
const filePath =
|
|
132
|
+
isToolCallEventType('write', event) || isToolCallEventType('edit', event)
|
|
133
|
+
? event.input.path
|
|
134
|
+
: '';
|
|
135
|
+
|
|
136
|
+
const result = engine.interceptWriteToolCall(event.toolName, filePath);
|
|
137
|
+
if (result.blocked) {
|
|
138
|
+
return { block: true, reason: result.message };
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// ─── All prompt templates and display messages ─────────────────────────────
|
|
2
|
+
// Single source of truth for every string the LLM or user sees.
|
|
3
|
+
|
|
4
|
+
import type { Difficulty, SpotMeState } from './types.js';
|
|
5
|
+
|
|
6
|
+
// ─── Difficulty labels ────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function difficultyLabel(d: Difficulty): string {
|
|
9
|
+
switch (d) {
|
|
10
|
+
case 'lite':
|
|
11
|
+
return 'signature + structure provided — implement the body';
|
|
12
|
+
case 'medium':
|
|
13
|
+
return 'signature provided — implement the logic';
|
|
14
|
+
case 'hard':
|
|
15
|
+
return 'spec only — design and implement from scratch';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ─── Comment syntax helpers ────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const EXT_COMMENT: Record<string, { open: string; close?: string }> = {
|
|
22
|
+
// C-style
|
|
23
|
+
ts: { open: '//' },
|
|
24
|
+
tsx: { open: '//' },
|
|
25
|
+
js: { open: '//' },
|
|
26
|
+
jsx: { open: '//' },
|
|
27
|
+
java: { open: '//' },
|
|
28
|
+
c: { open: '//' },
|
|
29
|
+
cpp: { open: '//' },
|
|
30
|
+
cs: { open: '//' },
|
|
31
|
+
go: { open: '//' },
|
|
32
|
+
swift: { open: '//' },
|
|
33
|
+
kt: { open: '//' },
|
|
34
|
+
rs: { open: '//' },
|
|
35
|
+
php: { open: '//' },
|
|
36
|
+
dart: { open: '//' },
|
|
37
|
+
// Hash-style
|
|
38
|
+
py: { open: '#' },
|
|
39
|
+
rb: { open: '#' },
|
|
40
|
+
sh: { open: '#' },
|
|
41
|
+
bash: { open: '#' },
|
|
42
|
+
zsh: { open: '#' },
|
|
43
|
+
yaml: { open: '#' },
|
|
44
|
+
yml: { open: '#' },
|
|
45
|
+
toml: { open: '#' },
|
|
46
|
+
r: { open: '#' },
|
|
47
|
+
// Block-style
|
|
48
|
+
html: { open: '<!--', close: '-->' },
|
|
49
|
+
xml: { open: '<!--', close: '-->' },
|
|
50
|
+
svg: { open: '<!--', close: '-->' },
|
|
51
|
+
css: { open: '/*', close: '*/' },
|
|
52
|
+
scss: { open: '//' },
|
|
53
|
+
sass: { open: '//' },
|
|
54
|
+
less: { open: '//' },
|
|
55
|
+
// Double-dash
|
|
56
|
+
lua: { open: '--' },
|
|
57
|
+
sql: { open: '--' },
|
|
58
|
+
// Lisp-style
|
|
59
|
+
el: { open: ';;' },
|
|
60
|
+
clj: { open: ';;' },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function commentForFile(filePath: string): string {
|
|
64
|
+
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
|
65
|
+
const syntax = EXT_COMMENT[ext] ?? { open: '//' };
|
|
66
|
+
return syntax.close
|
|
67
|
+
? `${syntax.open} SPOTME: <description> ${syntax.close}`
|
|
68
|
+
: `${syntax.open} SPOTME: <description>`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Display messages (returned to user / LLM as tool output) ──────────────
|
|
72
|
+
|
|
73
|
+
export function statusMessage(state: SpotMeState): string {
|
|
74
|
+
const lines = [
|
|
75
|
+
`SpotMe: ${state.enabled ? '🟢 on' : '⚪ off'}`,
|
|
76
|
+
`Difficulty: ${state.difficulty}`,
|
|
77
|
+
`Trigger every: ${state.every} code write(s)`,
|
|
78
|
+
`Counter: ${state.counter}/${state.every}`,
|
|
79
|
+
];
|
|
80
|
+
if (state.exercise?.active) {
|
|
81
|
+
lines.push(`Active exercise: ${state.exercise.unit} (${state.exercise.filePath})`);
|
|
82
|
+
}
|
|
83
|
+
return lines.join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function exerciseReadyMessage(
|
|
87
|
+
unit: string,
|
|
88
|
+
filePath: string,
|
|
89
|
+
difficulty: Difficulty
|
|
90
|
+
): string {
|
|
91
|
+
return [
|
|
92
|
+
`🏋️ Exercise ready: **${unit}**`,
|
|
93
|
+
`Difficulty: ${difficulty} — ${difficultyLabel(difficulty)}`,
|
|
94
|
+
`File: \`${filePath}\``,
|
|
95
|
+
``,
|
|
96
|
+
`Edit the file in your editor. Replace the \`# SPOTME:\` marker with your implementation.`,
|
|
97
|
+
``,
|
|
98
|
+
`Your options:`,
|
|
99
|
+
` \`/spotme:hint\` — get a targeted hint`,
|
|
100
|
+
` \`/spotme:solve\` — concede and let the agent finish`,
|
|
101
|
+
` \`/spotme:skip\` — skip this exercise`,
|
|
102
|
+
` \`/spotme:done\` — submit your implementation for review`,
|
|
103
|
+
].join('\n');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function blockedMessage(toolName: string, filePath: string, difficulty: Difficulty): string {
|
|
107
|
+
const marker = commentForFile(filePath);
|
|
108
|
+
const scaffoldStep =
|
|
109
|
+
toolName === 'edit' && filePath
|
|
110
|
+
? `Edit \`${filePath}\` to add a \`${marker}\` comment at the location where the implementation should go.`
|
|
111
|
+
: filePath
|
|
112
|
+
? `Write the scaffold to \`${filePath}\` using the Write tool. Include a \`${marker}\` comment where the implementation should go.`
|
|
113
|
+
: `Write the scaffold file using the Write tool. Include a \`${marker}\` comment (use appropriate comment syntax for the language) where the implementation should go.`;
|
|
114
|
+
|
|
115
|
+
return [
|
|
116
|
+
`[SpotMe] Counter reached — time for an exercise!`,
|
|
117
|
+
``,
|
|
118
|
+
`Follow these steps in order:`,
|
|
119
|
+
`1. ${scaffoldStep}`,
|
|
120
|
+
`2. Call \`spotme_exercise\` with the unit name, the file path, and difficulty "${difficulty}".`,
|
|
121
|
+
`3. Display the full return value of \`spotme_exercise\` verbatim to the user (do not summarize).`,
|
|
122
|
+
].join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── LLM instruction templates (injected as command templates / user messages) ─
|
|
126
|
+
|
|
127
|
+
export const PROMPTS = {
|
|
128
|
+
/** /spotme:on — confirm settings after hook already set state. */
|
|
129
|
+
ON: 'SpotMe gym mode was just activated. Call `spotme_status` to get the current settings, then confirm them to the user in one sentence.',
|
|
130
|
+
|
|
131
|
+
/** /spotme:off — brief confirmation. */
|
|
132
|
+
OFF: 'Confirm that SpotMe gym mode is now off and you will resume writing code normally.',
|
|
133
|
+
|
|
134
|
+
/** /spotme:status — display current state. */
|
|
135
|
+
STATUS: 'Call the `spotme_status` tool and display the result to the user.',
|
|
136
|
+
|
|
137
|
+
/** /spotme:done — review user's implementation, then close. */
|
|
138
|
+
DONE: "Call `spotme_status` to get the active exercise details. Read the exercise file. Evaluate the user's implementation: (1) what they got right — 1-2 sentences, specific; (2) what could be better — concrete, no vague feedback; (3) next steps only if incomplete. Do NOT show your own solution. Resume the original task and complete any remaining code. Call `spotme_end` as the LAST thing you do.",
|
|
139
|
+
|
|
140
|
+
/** /spotme:hint — one hint, no spoilers. */
|
|
141
|
+
HINT: 'Give one targeted hint for the current SpotMe exercise. Point toward the approach without revealing the implementation. One paragraph max.',
|
|
142
|
+
|
|
143
|
+
/** /spotme:solve — write solution BEFORE closing exercise. */
|
|
144
|
+
SOLVE:
|
|
145
|
+
'Call `spotme_status` to get the active exercise details. Read the exercise file. Write the solution (replace the SPOTME marker if still present, or improve what the user wrote). Briefly note the key pattern the user should remember. Resume the original task and complete any remaining code. Call `spotme_end` as the LAST thing you do.',
|
|
146
|
+
|
|
147
|
+
/** /spotme:skip — complete the code, then close. */
|
|
148
|
+
SKIP: 'The human is skipping this exercise. Resume the original task and complete the code normally. Call `spotme_end` as the LAST thing you do.',
|
|
149
|
+
|
|
150
|
+
/** /spotme:rep — on-demand exercise. */
|
|
151
|
+
REP: 'The human wants a coding exercise. Write the scaffold for the next logical unit using the Write tool (use a `# SPOTME: <description>` marker where the human should implement), then call `spotme_exercise` with the unit name, file path, and difficulty.',
|
|
152
|
+
} as const;
|