@genee/omp-opsx-addon 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 +83 -0
- package/index.ts +1327 -0
- package/lib/agent-defs.ts +291 -0
- package/lib/change-scanner.ts +72 -0
- package/lib/cursor-auth.ts +230 -0
- package/lib/direct-fetchers.ts +155 -0
- package/lib/edit-variant-pin.ts +156 -0
- package/lib/error-scan.ts +97 -0
- package/lib/family-filter.ts +174 -0
- package/lib/model-selector.ts +307 -0
- package/lib/model-tiers.ts +164 -0
- package/lib/notify.ts +4 -0
- package/lib/openspec-tasks.ts +28 -0
- package/lib/peak-detector.ts +44 -0
- package/lib/reachability-cache.ts +288 -0
- package/lib/reachability-probe.ts +459 -0
- package/lib/reachability-refresh.ts +84 -0
- package/lib/selection-filters.ts +100 -0
- package/lib/selection-lifecycle.ts +17 -0
- package/lib/session-title.ts +186 -0
- package/lib/session-usage.ts +252 -0
- package/lib/state.ts +95 -0
- package/lib/system-prompt.ts +160 -0
- package/lib/tiers-data.ts +136 -0
- package/lib/tiers-updater.ts +146 -0
- package/lib/unified-config.ts +463 -0
- package/lib/usage-estimator.ts +154 -0
- package/lib/usage-poller.ts +1200 -0
- package/lib/usage-poller.worker.ts +31 -0
- package/lib/usage-redis-client.ts +594 -0
- package/lib/usage-redis-multiproc.worker.ts +80 -0
- package/lib/usage-redis-probe.ts +389 -0
- package/lib/usage-render.ts +526 -0
- package/lib/usage-resolver.ts +477 -0
- package/lib/usage-sampler.ts +77 -0
- package/lib/usage-widget.ts +112 -0
- package/package.json +47 -0
- package/types/bun.d.ts +21 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { AgentEndEvent, ExtensionAPI, ExtensionContext } from '@oh-my-pi/pi-coding-agent';
|
|
2
|
+
import { readFile, readdir } from 'fs/promises';
|
|
3
|
+
|
|
4
|
+
type AgentMessage = AgentEndEvent['messages'][number];
|
|
5
|
+
import { existsSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { buildPrompt, parseState, detectChangeName } from './state.js';
|
|
9
|
+
import { notify } from './notify.js';
|
|
10
|
+
import { countTasks } from './openspec-tasks.js';
|
|
11
|
+
|
|
12
|
+
interface AuthEntry {
|
|
13
|
+
type: string;
|
|
14
|
+
key?: string;
|
|
15
|
+
}
|
|
16
|
+
interface PiSettings {
|
|
17
|
+
defaultProvider?: string;
|
|
18
|
+
defaultModel?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ENDPOINTS: Record<string, string> = {
|
|
22
|
+
deepseek: 'https://api.deepseek.com',
|
|
23
|
+
openai: 'https://api.openai.com/v1',
|
|
24
|
+
anthropic: 'https://api.anthropic.com',
|
|
25
|
+
google: 'https://generativelanguage.googleapis.com/v1beta',
|
|
26
|
+
'zhipuai-coding-plan': 'https://open.bigmodel.cn/api/paas/v4',
|
|
27
|
+
zhipuai: 'https://open.bigmodel.cn/api/paas/v4',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
async function readJSON<T>(path: string): Promise<T | null> {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(await readFile(path, 'utf-8')) as T;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function findCallableProvider(
|
|
39
|
+
directory: string,
|
|
40
|
+
): Promise<{ apiKey: string; baseURL: string; model: string } | null> {
|
|
41
|
+
const agentDir = join(homedir(), '.omp', 'agent');
|
|
42
|
+
const globalSettings = (await readJSON<PiSettings>(join(agentDir, 'settings.json'))) ?? {};
|
|
43
|
+
const projectSettings = (await readJSON<PiSettings>(join(directory, '.omp', 'settings.json'))) ?? {};
|
|
44
|
+
const settings = { ...globalSettings, ...projectSettings };
|
|
45
|
+
|
|
46
|
+
const targetPID = settings.defaultProvider?.trim() || 'deepseek';
|
|
47
|
+
const targetModel = settings.defaultModel?.trim() || 'deepseek-v4-flash';
|
|
48
|
+
|
|
49
|
+
const auth = await readJSON<Record<string, AuthEntry>>(join(agentDir, 'auth.json'));
|
|
50
|
+
if (auth?.[targetPID]?.key) {
|
|
51
|
+
return {
|
|
52
|
+
apiKey: auth[targetPID].key!,
|
|
53
|
+
baseURL: ENDPOINTS[targetPID] || `https://api.${targetPID}.com/v1`,
|
|
54
|
+
model: targetModel,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (auth) {
|
|
59
|
+
for (const [pid, entry] of Object.entries(auth)) {
|
|
60
|
+
if (entry.key) {
|
|
61
|
+
return {
|
|
62
|
+
apiKey: entry.key,
|
|
63
|
+
baseURL: ENDPOINTS[pid] || `https://api.${pid}.com/v1`,
|
|
64
|
+
model: targetModel,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function generateTitle(context: string, directory: string): Promise<string> {
|
|
74
|
+
const prov = await findCallableProvider(directory);
|
|
75
|
+
if (!prov) throw new Error('no provider available');
|
|
76
|
+
|
|
77
|
+
const prompt = buildPrompt(context);
|
|
78
|
+
const body: Record<string, unknown> = {
|
|
79
|
+
model: prov.model,
|
|
80
|
+
messages: [{ role: 'user', content: prompt }],
|
|
81
|
+
max_tokens: 80,
|
|
82
|
+
temperature: 0.3,
|
|
83
|
+
};
|
|
84
|
+
if (!prov.model.startsWith('o')) body.thinking = { type: 'disabled' };
|
|
85
|
+
|
|
86
|
+
const res = await fetch(`${prov.baseURL}/chat/completions`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${prov.apiKey}` },
|
|
89
|
+
body: JSON.stringify(body),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (!res.ok) throw new Error(`API ${res.status}`);
|
|
93
|
+
const json = (await res.json()) as {
|
|
94
|
+
choices?: Array<{ message?: { content?: string; reasoning_content?: string } }>;
|
|
95
|
+
};
|
|
96
|
+
const msg = json.choices?.[0]?.message;
|
|
97
|
+
return msg?.content?.trim() || msg?.reasoning_content?.trim() || '';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function messageText(msg: AgentMessage): string {
|
|
101
|
+
if (!('content' in msg)) return '';
|
|
102
|
+
const content = msg.content;
|
|
103
|
+
if (typeof content === 'string') return content.trim();
|
|
104
|
+
if (!Array.isArray(content)) return '';
|
|
105
|
+
return content
|
|
106
|
+
.filter((p): p is { type: 'text'; text: string } => typeof p === 'object' && p !== null && p.type === 'text')
|
|
107
|
+
.map((p) => (p as any).text)
|
|
108
|
+
.join('\n')
|
|
109
|
+
.trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function extractContext(messages: AgentMessage[]): string {
|
|
113
|
+
const turns: string[] = [];
|
|
114
|
+
let cur = '';
|
|
115
|
+
for (const m of messages) {
|
|
116
|
+
if (!('role' in m)) continue;
|
|
117
|
+
if (m.role !== 'user' && m.role !== 'assistant') continue;
|
|
118
|
+
const t = messageText(m);
|
|
119
|
+
if (!t) continue;
|
|
120
|
+
if (m.role === 'user') {
|
|
121
|
+
if (cur) turns.push(cur);
|
|
122
|
+
cur = `User: ${t.slice(0, 400)}`;
|
|
123
|
+
} else {
|
|
124
|
+
cur += `\nA: ${t.slice(0, 400)}`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (cur) turns.push(cur);
|
|
128
|
+
return turns.slice(-6).join('\n\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function findActiveChange(context: string, dir: string): Promise<string | undefined> {
|
|
132
|
+
const fromCtx = detectChangeName(context);
|
|
133
|
+
if (fromCtx) return fromCtx;
|
|
134
|
+
const ch = context.match(/CHANGE:\s*(\S+)/i);
|
|
135
|
+
if (ch) return ch[1];
|
|
136
|
+
const changesDir = join(dir, 'openspec', 'changes');
|
|
137
|
+
if (!existsSync(changesDir)) return undefined;
|
|
138
|
+
try {
|
|
139
|
+
const entries = await readdir(changesDir);
|
|
140
|
+
return entries.find((e) => e !== 'archive');
|
|
141
|
+
} catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function createTitleGenerator(pi: ExtensionAPI, directory: string, warn: (msg: string) => void) {
|
|
147
|
+
const titleCache = new Map<string, string>();
|
|
148
|
+
const stateCache = new Map<string, string>();
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
async onAgentEnd(event: AgentEndEvent, ctx: ExtensionContext) {
|
|
152
|
+
try {
|
|
153
|
+
const sessionId = ctx.sessionManager.getSessionFile() || 'default';
|
|
154
|
+
const ctx2 = extractContext(event.messages);
|
|
155
|
+
if (!ctx2) return;
|
|
156
|
+
if (titleCache.has(sessionId)) return;
|
|
157
|
+
|
|
158
|
+
const raw = await generateTitle(ctx2, directory);
|
|
159
|
+
const p = parseState(raw);
|
|
160
|
+
|
|
161
|
+
let progress: { current: number; total: number } | null = null;
|
|
162
|
+
if (p.state === 'implementing') {
|
|
163
|
+
const changeName = await findActiveChange(ctx2, directory);
|
|
164
|
+
if (changeName) progress = await countTasks(directory, changeName);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const progressTag = progress ? `${progress.current}/${progress.total}` : '';
|
|
168
|
+
const prefix = progress ? `${p.emoji}${progressTag}` : p.emoji;
|
|
169
|
+
const title = prefix ? `${prefix} ${p.title}` : p.title;
|
|
170
|
+
|
|
171
|
+
if (titleCache.get(sessionId) !== title) {
|
|
172
|
+
titleCache.set(sessionId, title);
|
|
173
|
+
pi.setSessionName(title);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const lastState = stateCache.get(sessionId);
|
|
177
|
+
if (p.state !== 'none' && p.state !== lastState) {
|
|
178
|
+
stateCache.set(sessionId, p.state);
|
|
179
|
+
notify('omp', `${p.emoji} ${p.state}: ${detectChangeName(ctx2) || p.title}`);
|
|
180
|
+
}
|
|
181
|
+
} catch (e) {
|
|
182
|
+
warn(`[omp-opsx-addon] session title generation failed: ${e}`);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session token-usage recorder feeding the usage widget's consumption
|
|
3
|
+
* waveforms (design Decision 5, revised: usage-RATE source).
|
|
4
|
+
*
|
|
5
|
+
* `message_end` fires once per assistant message with the turn's exact
|
|
6
|
+
* `usage.totalTokens`. Each plugin instance has its own recorder instance
|
|
7
|
+
* (see index.ts for the architecture). Subagent usage is captured through
|
|
8
|
+
* the eventBus channel mechanism (TASK_SUBAGENT_EVENT_CHANNEL) where the
|
|
9
|
+
* primary plugin subscribes to subagent events and records them with
|
|
10
|
+
* stream-specific tracking to prevent cross-stream contamination. Sessions in
|
|
11
|
+
* other processes instantiate their own recorder and join the aggregated
|
|
12
|
+
* view through the shared usage cache's `sessionUsage` node, which each
|
|
13
|
+
* process writes for its own session id and everyone aggregates on read
|
|
14
|
+
* (see usage-poller.ts). The recorder owns the process's counter, the write
|
|
15
|
+
* throttle, and the session-end cleanup; the poller owns the on-disk merge.
|
|
16
|
+
*
|
|
17
|
+
* Multi-stream tracking: The recorder maintains per-stream baselines
|
|
18
|
+
* (Map<streamKey, Map<provider, last基线>>) where each stream computes its own
|
|
19
|
+
* delta (usage.totalTokens − last[streamKey][provider], first call records
|
|
20
|
+
* full baseline). Global totals/seconds aggregate all stream deltas, so streams
|
|
21
|
+
* never compare across each other (no cross-stream MAX swallowing). The default
|
|
22
|
+
* streamKey is the recorder's current sessionId (legacy primary path).
|
|
23
|
+
* setSessionId() only sets the global sessionId and never clears stream baselines.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Min spacing between sessionUsage writes — 1s, matching the renderTimer so the
|
|
27
|
+
* throttle lets a fresh flush land each second (cross-process latency ≤1s). */
|
|
28
|
+
export const SESSION_USAGE_WRITE_MS = 1_000;
|
|
29
|
+
|
|
30
|
+
import type { UsageBucket } from "./usage-estimator.js";
|
|
31
|
+
|
|
32
|
+
/** Where the recorder persists its per-session totals. */
|
|
33
|
+
export interface SessionUsageSink {
|
|
34
|
+
/** Persist the session's totals. Returns false when nothing was persisted
|
|
35
|
+
* (lock/I/O failure) — the recorder keeps its dirty flag so the next
|
|
36
|
+
* flush retries the same totals; nothing is lost. */
|
|
37
|
+
write(sessionId: string, perProvider: Record<string, ProviderUsageTotals>): boolean;
|
|
38
|
+
remove(sessionId: string): void;
|
|
39
|
+
/** Optional: event-level per-second delta hook (the Redis mirror's event
|
|
40
|
+
* source). `record()` calls it synchronously for every `deltaTotal > 0`
|
|
41
|
+
* (before updating totals), skipping while no session id is set. The
|
|
42
|
+
* implementation MUST NOT throw — the recorder wraps the call in a guard
|
|
43
|
+
* so a failing hook can never break accounting. */
|
|
44
|
+
onDelta?(sessionId: string, provider: string, secNow: string, deltaTotal: number): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Per-provider cumulative totals PLUS the per-second delta map (timestamped
|
|
48
|
+
* increments; the waveform consumes the per-second sequence directly so a
|
|
49
|
+
* throttled flush can never mis-attribute tokens to the wrong second). */
|
|
50
|
+
export interface ProviderUsageTotals extends UsageBucket {
|
|
51
|
+
/** Per-second delta map: unix second → tokens (the waveform source). */
|
|
52
|
+
seconds?: Record<string, number>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Per-session token-usage recorder with multi-stream support.
|
|
56
|
+
*
|
|
57
|
+
* The recorder maintains a global sessionId for legacy compatibility and
|
|
58
|
+
* supports independent stream tracking (e.g., primary vs subagent streams)
|
|
59
|
+
* to prevent cross-stream contamination. Each stream maintains its own
|
|
60
|
+
* baseline per-provider, and deltas are computed independently and then
|
|
61
|
+
* aggregated into the global totals. This prevents setSessionId() from
|
|
62
|
+
* clearing other streams' baselines and ensures streams don't swallow each
|
|
63
|
+
* other's contributions (MAX operation per stream, not across streams).
|
|
64
|
+
*/
|
|
65
|
+
export interface SessionUsageRecorder {
|
|
66
|
+
/** Set the global session id for legacy primary stream (call once on claim). */
|
|
67
|
+
setSessionId(id: string): void;
|
|
68
|
+
/** Get the global session id. */
|
|
69
|
+
sessionId(): string;
|
|
70
|
+
/** Record usage with optional stream key for independent tracking. */
|
|
71
|
+
record(provider: string, usage: UsageBucket, streamKey?: string): void;
|
|
72
|
+
/** Throttled flush to the sink (auto-called on record; exposes for testing). */
|
|
73
|
+
flush(): boolean;
|
|
74
|
+
/** Session shutdown: clean up dirty state and persist final totals. */
|
|
75
|
+
remove(): void;
|
|
76
|
+
/** Get current totals (for testing only). */
|
|
77
|
+
totals(): Map<string, ProviderUsageTotals>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createSessionUsageRecorder(
|
|
81
|
+
sink: SessionUsageSink,
|
|
82
|
+
opts?: { writeMs?: number; now?: () => number },
|
|
83
|
+
): SessionUsageRecorder {
|
|
84
|
+
const writeMs = opts?.writeMs ?? SESSION_USAGE_WRITE_MS;
|
|
85
|
+
const now = opts?.now ?? Date.now;
|
|
86
|
+
|
|
87
|
+
let sessionId = '';
|
|
88
|
+
let dirty = false;
|
|
89
|
+
let lastFlush = 0;
|
|
90
|
+
|
|
91
|
+
// Global totals (aggregated from all streams)
|
|
92
|
+
const totals: Record<string, ProviderUsageTotals> = {};
|
|
93
|
+
const seconds: Record<string, Record<string, number>> = {};
|
|
94
|
+
|
|
95
|
+
// Per-stream baselines: Map<streamKey, Map<provider, last基线>>
|
|
96
|
+
const streamBaselines = new Map<string, Map<string, UsageBucket>>();
|
|
97
|
+
|
|
98
|
+
// Helper: get or create stream baseline map
|
|
99
|
+
const getStreamBaselines = (streamKey: string): Map<string, UsageBucket> => {
|
|
100
|
+
if (!streamBaselines.has(streamKey)) {
|
|
101
|
+
streamBaselines.set(streamKey, new Map());
|
|
102
|
+
}
|
|
103
|
+
return streamBaselines.get(streamKey)!;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Helper: get stream-specific baseline
|
|
107
|
+
const getStreamBaseline = (streamKey: string, provider: string): UsageBucket => {
|
|
108
|
+
const baselines = getStreamBaselines(streamKey);
|
|
109
|
+
if (!baselines.has(provider)) {
|
|
110
|
+
baselines.set(provider, { total: 0, input: 0, output: 0 });
|
|
111
|
+
}
|
|
112
|
+
return baselines.get(provider)!;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Helper: update stream baseline
|
|
116
|
+
const updateStreamBaseline = (streamKey: string, provider: string, usage: UsageBucket): void => {
|
|
117
|
+
const baselines = getStreamBaselines(streamKey);
|
|
118
|
+
baselines.set(provider, { ...usage });
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Helper: compute delta from baseline
|
|
122
|
+
const computeDelta = (streamKey: string, provider: string, current: UsageBucket): UsageBucket => {
|
|
123
|
+
const baseline = getStreamBaseline(streamKey, provider);
|
|
124
|
+
return {
|
|
125
|
+
total: Math.max(0, current.total - baseline.total),
|
|
126
|
+
input: Math.max(0, current.input - baseline.input),
|
|
127
|
+
output: Math.max(0, current.output - baseline.output),
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
setSessionId(id: string): void {
|
|
133
|
+
sessionId = id;
|
|
134
|
+
// IMPORTANT: setSessionId only sets the global sessionId
|
|
135
|
+
// and NEVER clears stream baselines to prevent cross-stream contamination
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
sessionId(): string {
|
|
139
|
+
return sessionId;
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
record(provider: string, usage: UsageBucket, streamKey?: string): void {
|
|
143
|
+
if (!sessionId) return; // No-op before claim
|
|
144
|
+
|
|
145
|
+
// Validate finite token counts
|
|
146
|
+
if (!Number.isFinite(usage.total) || !Number.isFinite(usage.input) || !Number.isFinite(usage.output)) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Use streamKey or default to sessionId for legacy compatibility
|
|
151
|
+
const key = streamKey ?? sessionId;
|
|
152
|
+
|
|
153
|
+
// Compute stream-specific delta
|
|
154
|
+
const delta = computeDelta(key, provider, usage);
|
|
155
|
+
|
|
156
|
+
// Skip if no delta (avoid unnecessary writes)
|
|
157
|
+
if (delta.total === 0 && delta.input === 0 && delta.output === 0) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Update stream baseline to current usage (MAX for monotonicity within stream)
|
|
162
|
+
updateStreamBaseline(key, provider, usage);
|
|
163
|
+
|
|
164
|
+
// Aggregate delta into global totals
|
|
165
|
+
if (!totals[provider]) {
|
|
166
|
+
totals[provider] = { total: 0, input: 0, output: 0, seconds: {} };
|
|
167
|
+
}
|
|
168
|
+
totals[provider].total += delta.total;
|
|
169
|
+
totals[provider].input += delta.input;
|
|
170
|
+
totals[provider].output += delta.output;
|
|
171
|
+
|
|
172
|
+
// Update per-second deltas (global, not per-stream)
|
|
173
|
+
const secNow = Math.floor(now() / 1000).toString();
|
|
174
|
+
if (!totals[provider].seconds) {
|
|
175
|
+
totals[provider].seconds = {};
|
|
176
|
+
}
|
|
177
|
+
totals[provider].seconds![secNow] = (totals[provider].seconds![secNow] ?? 0) + delta.total;
|
|
178
|
+
|
|
179
|
+
dirty = true;
|
|
180
|
+
|
|
181
|
+
// Event-level delta mirror (Redis)
|
|
182
|
+
if (delta.total > 0) {
|
|
183
|
+
try {
|
|
184
|
+
sink.onDelta?.(sessionId, provider, secNow, delta.total);
|
|
185
|
+
} catch {
|
|
186
|
+
// Silently ignore errors in mirror hook
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Throttled flush
|
|
191
|
+
const nowVal = now();
|
|
192
|
+
if (nowVal - lastFlush >= writeMs) {
|
|
193
|
+
lastFlush = nowVal;
|
|
194
|
+
if (flushInternal()) {
|
|
195
|
+
dirty = false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
flush(): boolean {
|
|
201
|
+
return flushInternal();
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
remove(): void {
|
|
205
|
+
if (sessionId) {
|
|
206
|
+
sink.remove(sessionId);
|
|
207
|
+
}
|
|
208
|
+
// Clear internal state
|
|
209
|
+
sessionId = '';
|
|
210
|
+
Object.keys(totals).forEach(k => delete totals[k]);
|
|
211
|
+
Object.keys(seconds).forEach(k => delete seconds[k]);
|
|
212
|
+
streamBaselines.clear();
|
|
213
|
+
dirty = false;
|
|
214
|
+
lastFlush = 0;
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
totals(): Map<string, ProviderUsageTotals> {
|
|
218
|
+
return new Map(Object.entries(totals).map(([k, v]) => [k, { ...v }]));
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
function flushInternal(): boolean {
|
|
223
|
+
if (!sessionId || !dirty) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
return sink.write(sessionId, totals);
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Extract the billable usage of an assistant `message_end` / `message_update`
|
|
237
|
+
* event payload. Returns `undefined` for non-usage events.
|
|
238
|
+
*/
|
|
239
|
+
export function assistantUsageOf(
|
|
240
|
+
message: unknown,
|
|
241
|
+
): { provider: string; usage: UsageBucket } | undefined {
|
|
242
|
+
if (!message || typeof message !== 'object') return undefined;
|
|
243
|
+
// External event payload; structural guard instead of a full schema parse.
|
|
244
|
+
const m = message as { role?: unknown; provider?: unknown; usage?: { totalTokens?: unknown; input?: unknown; output?: unknown; cacheRead?: unknown } };
|
|
245
|
+
if (m.role !== 'assistant' || typeof m.provider !== 'string' || m.provider === '') return undefined;
|
|
246
|
+
const total = m.usage?.totalTokens;
|
|
247
|
+
if (typeof total !== 'number' || !Number.isFinite(total)) return undefined;
|
|
248
|
+
const input = (typeof m.usage?.input === 'number' && Number.isFinite(m.usage.input) ? m.usage.input : 0) +
|
|
249
|
+
(typeof m.usage?.cacheRead === 'number' && Number.isFinite(m.usage.cacheRead) ? m.usage.cacheRead : 0);
|
|
250
|
+
const output = typeof m.usage?.output === 'number' && Number.isFinite(m.usage.output) ? m.usage.output : 0;
|
|
251
|
+
return { provider: m.provider, usage: { total, input, output } };
|
|
252
|
+
}
|
package/lib/state.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const STATE_MAP: Record<string, string> = {
|
|
2
|
+
exploring: '💡',
|
|
3
|
+
proposing: '📝',
|
|
4
|
+
implementing: '👨🏻💻',
|
|
5
|
+
reviewing: '🛡️',
|
|
6
|
+
complete: '✅',
|
|
7
|
+
blocked: '🚫',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export interface StateResult {
|
|
11
|
+
state: string;
|
|
12
|
+
emoji: string;
|
|
13
|
+
title: string;
|
|
14
|
+
changeName?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseState(raw: string): StateResult {
|
|
18
|
+
const lines = raw
|
|
19
|
+
.replace(/<think[\s\S]*?<\/think>\s*/g, '')
|
|
20
|
+
.split('\n')
|
|
21
|
+
.map((l) => l.trim())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
|
|
24
|
+
let state = 'none';
|
|
25
|
+
let title = '';
|
|
26
|
+
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
const lower = line.toLowerCase();
|
|
29
|
+
if (lower.startsWith('state:')) {
|
|
30
|
+
state = line
|
|
31
|
+
.replace(/^state:\s*/i, '')
|
|
32
|
+
.trim()
|
|
33
|
+
.toLowerCase();
|
|
34
|
+
} else if (lower.startsWith('title:')) {
|
|
35
|
+
title = line.replace(/^title:\s*/i, '').trim();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!title) {
|
|
40
|
+
title = lines.find((l) => !/^(state|change|progress):/i.test(l)) || 'Untitled';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// strip state emojis from title if AI included them (repeatedly)
|
|
44
|
+
const emojis = Object.values(STATE_MAP).sort((a, b) => b.length - a.length);
|
|
45
|
+
while (true) {
|
|
46
|
+
const before = title;
|
|
47
|
+
for (const e of emojis) {
|
|
48
|
+
if (title.startsWith(e)) {
|
|
49
|
+
title = title.slice(e.length).trim();
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (title === before) break;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { state, emoji: state !== 'none' ? STATE_MAP[state] || '' : '', title };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function detectChangeName(context: string): string | undefined {
|
|
60
|
+
const match = context.match(/openspec\/changes\/([a-z0-9-]+)/i);
|
|
61
|
+
return match?.[1];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildPrompt(context: string): string {
|
|
65
|
+
return `你是会话标题生成器。根据对话内容判断当前 OpenSpec 工作流状态并生成标题。
|
|
66
|
+
|
|
67
|
+
## 工作流状态
|
|
68
|
+
|
|
69
|
+
- exploring: 💡 探索、讨论或分析阶段
|
|
70
|
+
- proposing: 📝 正在创建提案或其 artifacts
|
|
71
|
+
- implementing: 👨🏻💻 @coder 正在实现任务
|
|
72
|
+
- reviewing: 🛡️ @reviewer 正在审查代码
|
|
73
|
+
- complete: ✅ 实现已通过审查
|
|
74
|
+
- blocked: 🚫 实现或审查被阻塞
|
|
75
|
+
|
|
76
|
+
## 输出格式(严格遵守)
|
|
77
|
+
|
|
78
|
+
第一行: state: <状态名>(如果非 OpenSpec 相关,输出 state: none)
|
|
79
|
+
第二行: title: <标题文本,≤50字符>
|
|
80
|
+
|
|
81
|
+
## 规则
|
|
82
|
+
|
|
83
|
+
- 使用中文
|
|
84
|
+
- 标题 ≤50 字符
|
|
85
|
+
- 保留技术术语、文件名
|
|
86
|
+
- 如果对话不涉及 OpenSpec 变更,state 输出 none,title 不加前缀
|
|
87
|
+
- state 不明确时用最早的相关状态
|
|
88
|
+
- 不要解释,只输出两行
|
|
89
|
+
|
|
90
|
+
## 对话内容
|
|
91
|
+
|
|
92
|
+
${context}
|
|
93
|
+
|
|
94
|
+
Output:`;
|
|
95
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { ChangeInfo } from './change-scanner.js';
|
|
2
|
+
import type { ResolvedOpsxConfig } from './unified-config.js';
|
|
3
|
+
import type { SelectorResult } from './model-selector.js';
|
|
4
|
+
|
|
5
|
+
/** Base prompt is ~17 lines; reserve headroom for truncation notice. */
|
|
6
|
+
export const MAX_PROMPT_LINES = 65;
|
|
7
|
+
export const MAX_LISTED_CHANGES = 25;
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
function formatChangeLine(change: ChangeInfo): string {
|
|
11
|
+
if (change.status === 'doing') {
|
|
12
|
+
return `• ${change.name}: doing (${change.completed}/${change.total})`;
|
|
13
|
+
}
|
|
14
|
+
if (change.status === 'done') {
|
|
15
|
+
return `• ${change.name}: done`;
|
|
16
|
+
}
|
|
17
|
+
return `• ${change.name}: ready`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildChangeSummary(changes: ChangeInfo[], maxListed?: number): string {
|
|
21
|
+
if (changes.length === 0) return '当前没有活跃的 OpenSpec 变更';
|
|
22
|
+
|
|
23
|
+
const limit = maxListed ?? MAX_LISTED_CHANGES;
|
|
24
|
+
const listed = changes.slice(0, limit);
|
|
25
|
+
const lines = listed.map(formatChangeLine);
|
|
26
|
+
const omitted = changes.length - listed.length;
|
|
27
|
+
if (omitted > 0) {
|
|
28
|
+
lines.push(`• … 还有 ${omitted} 个变更未列出`);
|
|
29
|
+
}
|
|
30
|
+
return lines.join('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Format a single role's resolved config + selector result for the dispatch table. */
|
|
34
|
+
function buildDispatchConfigLine(
|
|
35
|
+
role: string,
|
|
36
|
+
config: ResolvedOpsxConfig,
|
|
37
|
+
selector?: SelectorResult,
|
|
38
|
+
): string {
|
|
39
|
+
const roleValue = config.roles[role as keyof typeof config.roles];
|
|
40
|
+
if (!roleValue) return `• ${role}: <unknown role>`;
|
|
41
|
+
|
|
42
|
+
let modelText: string;
|
|
43
|
+
if (selector?.picked) {
|
|
44
|
+
modelText = `${selector.picked.provider}/${selector.picked.id} (tier=${selector.expectedTier})`;
|
|
45
|
+
} else if (roleValue.kind === 'auto') {
|
|
46
|
+
modelText = '<no model available>';
|
|
47
|
+
} else if (roleValue.kind === 'omp-alias') {
|
|
48
|
+
modelText = `@${roleValue.alias} (OMP 解析中)`;
|
|
49
|
+
} else if (roleValue.kind === 'provider-model') {
|
|
50
|
+
modelText = `${roleValue.provider}/${roleValue.modelId}`;
|
|
51
|
+
} else {
|
|
52
|
+
modelText = roleValue.modelId;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const valueKind =
|
|
56
|
+
roleValue.kind === 'auto'
|
|
57
|
+
? 'auto'
|
|
58
|
+
: roleValue.kind === 'omp-alias'
|
|
59
|
+
? `@${roleValue.alias}`
|
|
60
|
+
: 'pin';
|
|
61
|
+
return `• ${role}: ${modelText} (${valueKind})`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildDispatchConfig(
|
|
65
|
+
config: ResolvedOpsxConfig,
|
|
66
|
+
selectorResults: Record<string, SelectorResult> | undefined,
|
|
67
|
+
): string {
|
|
68
|
+
return [
|
|
69
|
+
buildDispatchConfigLine('coder', config, selectorResults?.coder),
|
|
70
|
+
buildDispatchConfigLine('planner', config, selectorResults?.planner),
|
|
71
|
+
buildDispatchConfigLine('proposal-reviewer', config, selectorResults?.['proposal-reviewer']),
|
|
72
|
+
buildDispatchConfigLine('reviewer', config, selectorResults?.reviewer),
|
|
73
|
+
].join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Static orchestration rules — identical every turn, enabling prefix cache hits.
|
|
77
|
+
* Contains zero dynamic data (no model picks, no change status). */
|
|
78
|
+
export function buildStaticPrompt(): string {
|
|
79
|
+
return `# OpenSpec 编排助手
|
|
80
|
+
|
|
81
|
+
严格按工作流驱动每个 session。
|
|
82
|
+
|
|
83
|
+
## 默认行为
|
|
84
|
+
- 新 session 默认进入 Explore 模式;用 \`skill://openspec-explore\` 作思考伙伴。
|
|
85
|
+
- "Don't implement" guardrail 仅适用 Explore 阶段;用户确认后必须切换为编排。
|
|
86
|
+
- 代码只能由 coder 子 agent 产出,禁止在 Explore 阶段直接写代码。
|
|
87
|
+
|
|
88
|
+
## 提案拆分原则
|
|
89
|
+
- 按契约独立性拆细提案:两部分间有可早期冻结的接口(文件格式/API/数据结构) → 拆成多个小变更,各自独立评审归档,避免又大又细的提案。
|
|
90
|
+
- 大需求用纲领(roadmap)change 分层管理:纲领只列阶段/依赖/验收口径,不含实现细节;每个阶段=一个独立 change。
|
|
91
|
+
- planner 接大需求先评估契约边界、输出拆分建议(契约+提案清单)待主 agent 确认后再开写;主 agent 规划时优先拆分,可并行派多个小 planner。
|
|
92
|
+
- 中间审查轮按 delta 审,终审才全量复核。
|
|
93
|
+
|
|
94
|
+
## 路由表
|
|
95
|
+
| 意图 | 委派 |
|
|
96
|
+
| 看代码/搜代码/理解架构 | task(agent:"scout", task:"探索问题") |
|
|
97
|
+
| 创建/更新提案(含 Budget 估算) | task(agent:"planner", task:"...") |
|
|
98
|
+
| 审查提案 | task(agent:"proposal-reviewer", task:"...") |
|
|
99
|
+
| 实现变更 | task(agent:"coder", task:"...") + openspec-apply-change |
|
|
100
|
+
| 审查代码实现 | task(agent:"reviewer", task:"审阅代码 + 执行全局验证(lint/单测/e2e)") |
|
|
101
|
+
|
|
102
|
+
## 2 个 Loop
|
|
103
|
+
- **Loop 1 – Propose → Review**:planner 完成 → proposal-reviewer → P0/P1 发回 → 通过告知用户。Propose-review loop:最多 planner→reviewer 往复 2 轮。
|
|
104
|
+
- **Loop 2 – Code → Review**:coder 完成(交付前自验证)→ code-reviewer 审阅 + 全局验证 → P0/P1 发回修复 → 仅 P2+ 视为通过。Code-review loop:最多 coder→reviewer 往复 3 轮(含首次实现),reviewer 兼执行全局验证。
|
|
105
|
+
- 达上限时停止自动流转,向用户说明未解决的问题并请求决策。
|
|
106
|
+
|
|
107
|
+
## /goal 集成(可选)
|
|
108
|
+
Planner 在 proposal 末尾输出 \`## Budget Estimate\`;主 agent 提取用于 \`/goal\`。
|
|
109
|
+
|
|
110
|
+
## 自动流程协作
|
|
111
|
+
coder 输出 STATUS: blocked 且含 SESSION: → 阅读阻塞原因决策;收到 P0/P1 → 修复再审。
|
|
112
|
+
|
|
113
|
+
## 上下文卫生(主 session 预算)
|
|
114
|
+
- 代码调研/搜代码/理解架构 → task(agent:"scout");主 session 不亲自 read 全文调研。
|
|
115
|
+
- 联网检索/外部资料 → task 子 agent(或 harness 内建 exa);web_search 结果不直接进主上下文。
|
|
116
|
+
- 大文件用 read 的 offset/limit 分段,不整文件读入。
|
|
117
|
+
- 子 agent 报告即最终形态:主 session 禁止复读子 agent 已读过的文件。
|
|
118
|
+
- 主 session 上下文超过 ~50k tokens:必须先委托再继续,禁止继续亲自调研。`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const STATIC_LINE_COUNT = buildStaticPrompt().split('\n').length;
|
|
122
|
+
|
|
123
|
+
/** Dynamic layer: Dispatch config + change status. Placed after the static
|
|
124
|
+
* layer so only this tail is re-prefilled when it changes between turns. */
|
|
125
|
+
export function buildDynamicPrompt(
|
|
126
|
+
changes: ChangeInfo[],
|
|
127
|
+
config: ResolvedOpsxConfig,
|
|
128
|
+
selectorResults?: Record<string, SelectorResult>,
|
|
129
|
+
): string {
|
|
130
|
+
const dispatchCfg = buildDispatchConfig(config, selectorResults);
|
|
131
|
+
|
|
132
|
+
let maxListed = MAX_LISTED_CHANGES;
|
|
133
|
+
let result: string;
|
|
134
|
+
|
|
135
|
+
do {
|
|
136
|
+
const changeSummary = buildChangeSummary(changes, maxListed);
|
|
137
|
+
result = `## Dispatch 配置\n${dispatchCfg}\n\n## 当前变更状态\n${changeSummary}`;
|
|
138
|
+
// Reserve the '\n\n' separator line between static and dynamic layers
|
|
139
|
+
// (design.md Decision 2): 22 changes must not produce a 66-line prompt.
|
|
140
|
+
if (countPromptLines(result) <= MAX_PROMPT_LINES - (STATIC_LINE_COUNT + 1)) break;
|
|
141
|
+
maxListed = Math.floor(maxListed / 2);
|
|
142
|
+
} while (maxListed > 0);
|
|
143
|
+
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Full prompt (static + dynamic) — for tests and single-string consumers. */
|
|
148
|
+
export function buildSystemPrompt(
|
|
149
|
+
changes: ChangeInfo[],
|
|
150
|
+
config: ResolvedOpsxConfig,
|
|
151
|
+
includeChanges: boolean = true,
|
|
152
|
+
selectorResults?: Record<string, SelectorResult>,
|
|
153
|
+
): string {
|
|
154
|
+
if (!includeChanges) return buildStaticPrompt();
|
|
155
|
+
return buildStaticPrompt() + '\n\n' + buildDynamicPrompt(changes, config, selectorResults);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function countPromptLines(prompt: string): number {
|
|
159
|
+
return prompt.split('\n').length;
|
|
160
|
+
}
|