@fixy/core 0.0.2 → 0.0.4
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/.omc/state/idle-notif-cooldown.json +3 -0
- package/.omc/state/last-tool-error.json +7 -0
- package/dist/__tests__/fixy-commands.test.js +172 -3
- package/dist/__tests__/fixy-commands.test.js.map +1 -1
- package/dist/fixy-commands.d.ts +1 -0
- package/dist/fixy-commands.d.ts.map +1 -1
- package/dist/fixy-commands.js +186 -3
- package/dist/fixy-commands.js.map +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +2 -2
- package/dist/store.js.map +1 -1
- package/package.json +5 -2
- package/src/__tests__/fixy-commands.test.ts +195 -3
- package/src/__tests__/slugify.test.ts +290 -0
- package/src/fixy-commands.ts +233 -6
- package/src/index.ts +2 -0
- package/src/slugify.ts +34 -0
- package/src/store.ts +2 -1
package/src/fixy-commands.ts
CHANGED
|
@@ -36,7 +36,7 @@ export class FixyCommandRunner {
|
|
|
36
36
|
await this._handleWorker(args, ctx);
|
|
37
37
|
break;
|
|
38
38
|
case '/all':
|
|
39
|
-
await this._handleAll(ctx);
|
|
39
|
+
await this._handleAll(args, ctx);
|
|
40
40
|
break;
|
|
41
41
|
case '/settings':
|
|
42
42
|
await this._handleSettings(ctx);
|
|
@@ -76,11 +76,238 @@ export class FixyCommandRunner {
|
|
|
76
76
|
ctx.thread.workerModel = adapterId;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
private async _handleAll(ctx: FixyCommandContext): Promise<void> {
|
|
80
|
-
|
|
81
|
-
'
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
private async _handleAll(prompt: string, ctx: FixyCommandContext): Promise<void> {
|
|
80
|
+
if (!prompt.trim()) {
|
|
81
|
+
await this._appendSystemMessage('/all requires a prompt — usage: @fixy /all <prompt>', ctx);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const allAdapters = ctx.registry.list();
|
|
86
|
+
if (allAdapters.length === 0) {
|
|
87
|
+
await this._appendSystemMessage('/all requires at least one registered adapter', ctx);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const workerId = ctx.thread.workerModel ?? allAdapters[0]!.id;
|
|
92
|
+
const workerAdapter = ctx.registry.require(workerId);
|
|
93
|
+
const thinkers = allAdapters.filter((a) => a.id !== workerId);
|
|
94
|
+
const soloMode = thinkers.length === 0;
|
|
95
|
+
|
|
96
|
+
const log = (msg: string): void => {
|
|
97
|
+
ctx.onLog('stdout', msg);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Helpers to call an adapter and record its response
|
|
101
|
+
const callAdapter = async (
|
|
102
|
+
adapter: typeof workerAdapter,
|
|
103
|
+
adapterPrompt: string,
|
|
104
|
+
): Promise<string> => {
|
|
105
|
+
const runId = randomUUID();
|
|
106
|
+
const execCtx: FixyExecutionContext = {
|
|
107
|
+
runId,
|
|
108
|
+
agent: { id: adapter.id, name: adapter.name },
|
|
109
|
+
threadContext: {
|
|
110
|
+
threadId: ctx.thread.id,
|
|
111
|
+
projectRoot: ctx.thread.projectRoot,
|
|
112
|
+
worktreePath: ctx.thread.projectRoot,
|
|
113
|
+
repoRef: null,
|
|
114
|
+
},
|
|
115
|
+
messages: ctx.thread.messages,
|
|
116
|
+
prompt: adapterPrompt,
|
|
117
|
+
session: ctx.thread.agentSessions[adapter.id] ?? null,
|
|
118
|
+
onLog: ctx.onLog,
|
|
119
|
+
onMeta: () => {},
|
|
120
|
+
onSpawn: () => {},
|
|
121
|
+
signal: ctx.signal,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const result = await adapter.execute(execCtx);
|
|
125
|
+
ctx.thread.agentSessions[adapter.id] = result.session;
|
|
126
|
+
|
|
127
|
+
const agentMsg: FixyMessage = {
|
|
128
|
+
id: randomUUID(),
|
|
129
|
+
createdAt: new Date().toISOString(),
|
|
130
|
+
role: 'agent',
|
|
131
|
+
agentId: adapter.id,
|
|
132
|
+
content: result.summary,
|
|
133
|
+
runId,
|
|
134
|
+
dispatchedTo: [],
|
|
135
|
+
patches: result.patches,
|
|
136
|
+
warnings: result.warnings,
|
|
137
|
+
};
|
|
138
|
+
await ctx.store.appendMessage(ctx.thread.id, ctx.thread.projectRoot, agentMsg);
|
|
139
|
+
|
|
140
|
+
return result.summary;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// ── PHASE 1: DISCUSSION ──
|
|
144
|
+
let discussionLog: Array<{ agentId: string; content: string }> = [];
|
|
145
|
+
|
|
146
|
+
if (soloMode) {
|
|
147
|
+
log('\n[fixy /all] Solo mode — skipping discussion phase\n');
|
|
148
|
+
} else {
|
|
149
|
+
log('\n[fixy /all] Phase 1: discussion\n');
|
|
150
|
+
const systemFraming =
|
|
151
|
+
'You are a thinker agent. Discuss this task with the other agents. Goal: agree on a full implementation plan.';
|
|
152
|
+
|
|
153
|
+
for (let round = 1; round <= 5; round++) {
|
|
154
|
+
log(`\n[fixy /all] Phase 1: discussion round ${round}/5\n`);
|
|
155
|
+
|
|
156
|
+
let allAgree = true;
|
|
157
|
+
for (const thinker of thinkers) {
|
|
158
|
+
const threadContext = discussionLog
|
|
159
|
+
.map((e) => `[${e.agentId}]: ${e.content}`)
|
|
160
|
+
.join('\n\n');
|
|
161
|
+
|
|
162
|
+
const thinkerPrompt =
|
|
163
|
+
round === 1
|
|
164
|
+
? `${systemFraming}\n\nUser task: ${prompt}` +
|
|
165
|
+
(threadContext ? `\n\nDiscussion so far:\n${threadContext}` : '')
|
|
166
|
+
: `${systemFraming}\n\nUser task: ${prompt}\n\nDiscussion so far:\n${threadContext}`;
|
|
167
|
+
|
|
168
|
+
const response = await callAdapter(thinker, thinkerPrompt);
|
|
169
|
+
discussionLog.push({ agentId: thinker.id, content: response });
|
|
170
|
+
|
|
171
|
+
const lower = response.toLowerCase();
|
|
172
|
+
const agreeSignals = ['agree', 'looks good', 'lgtm', 'i agree with the plan'];
|
|
173
|
+
if (!agreeSignals.some((s) => lower.includes(s))) {
|
|
174
|
+
allAgree = false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (allAgree) {
|
|
179
|
+
log('\n[fixy /all] Phase 1: all thinkers agree — ending discussion\n');
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── PHASE 2: PLAN BREAKDOWN ──
|
|
186
|
+
log('\n[fixy /all] Phase 2: plan breakdown\n');
|
|
187
|
+
|
|
188
|
+
const planPrompt =
|
|
189
|
+
'Break the agreed plan into ordered TODO items. Each TODO must be a concrete, scoped coding instruction. Output ONLY a numbered list, max 20 items total, no prose.';
|
|
190
|
+
|
|
191
|
+
const planContext = discussionLog.map((e) => `[${e.agentId}]: ${e.content}`).join('\n\n');
|
|
192
|
+
const fullPlanPrompt = planContext
|
|
193
|
+
? `User task: ${prompt}\n\nDiscussion:\n${planContext}\n\n${planPrompt}`
|
|
194
|
+
: `User task: ${prompt}\n\n${planPrompt}`;
|
|
195
|
+
|
|
196
|
+
let todos: string[] = [];
|
|
197
|
+
|
|
198
|
+
if (soloMode) {
|
|
199
|
+
const response = await callAdapter(workerAdapter, fullPlanPrompt);
|
|
200
|
+
todos = this._parseTodoList(response);
|
|
201
|
+
} else {
|
|
202
|
+
const responses: string[] = [];
|
|
203
|
+
for (const thinker of thinkers) {
|
|
204
|
+
const response = await callAdapter(thinker, fullPlanPrompt);
|
|
205
|
+
responses.push(response);
|
|
206
|
+
}
|
|
207
|
+
// Merge and deduplicate
|
|
208
|
+
const allTodos = responses.flatMap((r) => this._parseTodoList(r));
|
|
209
|
+
const seen = new Set<string>();
|
|
210
|
+
for (const todo of allTodos) {
|
|
211
|
+
if (!seen.has(todo)) {
|
|
212
|
+
seen.add(todo);
|
|
213
|
+
todos.push(todo);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Cap at 20
|
|
219
|
+
todos = todos.slice(0, 20);
|
|
220
|
+
|
|
221
|
+
if (todos.length === 0) {
|
|
222
|
+
await this._appendSystemMessage('/all failed — could not extract TODO items from plan', ctx);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
log(`\n[fixy /all] Phase 2: ${todos.length} TODO items extracted\n`);
|
|
227
|
+
|
|
228
|
+
// ── PHASE 3+4: WORKER EXECUTION + REVIEW (batches of 5) ──
|
|
229
|
+
const batches: string[][] = [];
|
|
230
|
+
for (let i = 0; i < todos.length; i += 5) {
|
|
231
|
+
batches.push(todos.slice(i, i + 5));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
|
|
235
|
+
const batch = batches[batchIdx]!;
|
|
236
|
+
log(
|
|
237
|
+
`\n[fixy /all] Phase 3: worker executing batch ${batchIdx + 1}/${batches.length} (${batch.length} TODOs)\n`,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const batchList = batch.map((t, i) => `${i + 1}. ${t}`).join('\n');
|
|
241
|
+
const workerPrompt = `Execute these TODO items exactly as written. Write the actual code. Report what you did for each item.\n\n${batchList}`;
|
|
242
|
+
|
|
243
|
+
let workerOutput = await callAdapter(workerAdapter, workerPrompt);
|
|
244
|
+
|
|
245
|
+
// Review loop (skip in solo mode)
|
|
246
|
+
if (!soloMode) {
|
|
247
|
+
let approved = false;
|
|
248
|
+
for (let attempt = 0; attempt < 2 && !approved; attempt++) {
|
|
249
|
+
log(
|
|
250
|
+
`\n[fixy /all] Phase 4: review of batch ${batchIdx + 1} (attempt ${attempt + 1}/2)\n`,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
let issues: string[] = [];
|
|
254
|
+
for (const thinker of thinkers) {
|
|
255
|
+
const reviewPrompt = `Review this worker output. Did it implement the TODOs correctly? Reply ONLY with: APPROVED or ISSUES: <description>.\n\nTODOs:\n${batchList}\n\nWorker output:\n${workerOutput}`;
|
|
256
|
+
const reviewResponse = await callAdapter(thinker, reviewPrompt);
|
|
257
|
+
|
|
258
|
+
if (reviewResponse.toUpperCase().includes('ISSUES')) {
|
|
259
|
+
issues.push(reviewResponse);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (issues.length === 0) {
|
|
264
|
+
approved = true;
|
|
265
|
+
log(`\n[fixy /all] Phase 4: batch ${batchIdx + 1} approved\n`);
|
|
266
|
+
} else {
|
|
267
|
+
const fixPrompt = `The reviewers found issues with your implementation. Fix them:\n\n${issues.join('\n\n')}\n\nOriginal TODOs:\n${batchList}`;
|
|
268
|
+
workerOutput = await callAdapter(workerAdapter, fixPrompt);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── PHASE 5: FINAL REVIEW ──
|
|
276
|
+
log('\n[fixy /all] Phase 5: final review\n');
|
|
277
|
+
|
|
278
|
+
if (!soloMode) {
|
|
279
|
+
const threadSummary = ctx.thread.messages
|
|
280
|
+
.filter((m) => m.role === 'agent')
|
|
281
|
+
.map((m) => `[${m.agentId}]: ${m.content}`)
|
|
282
|
+
.join('\n\n');
|
|
283
|
+
|
|
284
|
+
const finalPrompt = `This is the final output. Do a complete review. Reply ONLY with: APPROVED or ISSUES: <description>.\n\n${threadSummary}`;
|
|
285
|
+
|
|
286
|
+
const finalResults: string[] = [];
|
|
287
|
+
for (const thinker of thinkers) {
|
|
288
|
+
const response = await callAdapter(thinker, finalPrompt);
|
|
289
|
+
finalResults.push(`[${thinker.id}]: ${response}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
await this._appendSystemMessage(
|
|
293
|
+
`[fixy /all] collaboration complete\n\nFinal review:\n${finalResults.join('\n')}`,
|
|
294
|
+
ctx,
|
|
295
|
+
);
|
|
296
|
+
} else {
|
|
297
|
+
await this._appendSystemMessage('[fixy /all] collaboration complete (solo mode)', ctx);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private _parseTodoList(text: string): string[] {
|
|
302
|
+
const lines = text.split('\n');
|
|
303
|
+
const todos: string[] = [];
|
|
304
|
+
for (const line of lines) {
|
|
305
|
+
const match = line.match(/^\s*\d+[\.\)]\s+(.+)/);
|
|
306
|
+
if (match?.[1]) {
|
|
307
|
+
todos.push(match[1].trim());
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return todos;
|
|
84
311
|
}
|
|
85
312
|
|
|
86
313
|
private async _handleSettings(ctx: FixyCommandContext): Promise<void> {
|
package/src/index.ts
CHANGED
package/src/slugify.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const TRANSLITERATION_MAP: Record<string, string> = {
|
|
2
|
+
ß: 'ss',
|
|
3
|
+
æ: 'ae',
|
|
4
|
+
Æ: 'ae',
|
|
5
|
+
œ: 'oe',
|
|
6
|
+
Œ: 'oe',
|
|
7
|
+
ø: 'o',
|
|
8
|
+
Ø: 'o',
|
|
9
|
+
đ: 'd',
|
|
10
|
+
Đ: 'd',
|
|
11
|
+
ð: 'd',
|
|
12
|
+
Ð: 'd',
|
|
13
|
+
þ: 'th',
|
|
14
|
+
Þ: 'th',
|
|
15
|
+
ł: 'l',
|
|
16
|
+
Ł: 'l',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function slugify(input: string): string {
|
|
20
|
+
return input
|
|
21
|
+
.normalize('NFKD')
|
|
22
|
+
.replace(/[^\u0000-\u007F]/g, (ch) => TRANSLITERATION_MAP[ch] ?? '')
|
|
23
|
+
// 1. Convert to lowercase
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
// 2. Remove Unicode combining marks produced by normalization
|
|
26
|
+
.replace(/\p{M}/gu, '')
|
|
27
|
+
// 3. Remove apostrophes so contractions collapse without a separator
|
|
28
|
+
.replace(/['\u2018\u2019\u02bc]/g, '')
|
|
29
|
+
// 4. Replace each contiguous run of non-ASCII-alphanumeric characters with a single hyphen
|
|
30
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
31
|
+
// 5. Collapse repeated hyphens into one hyphen
|
|
32
|
+
.replace(/-{2,}/g, '-')
|
|
33
|
+
.replace(/^-+|-+$/g, '');
|
|
34
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
+
import { v7 as uuidv7 } from 'uuid';
|
|
6
7
|
|
|
7
8
|
import type { FixyMessage, FixyThread } from './thread.js';
|
|
8
9
|
import {
|
|
@@ -31,7 +32,7 @@ export class LocalThreadStore {
|
|
|
31
32
|
* Writes project.json if it does not already exist.
|
|
32
33
|
*/
|
|
33
34
|
async createThread(projectRoot: string): Promise<FixyThread> {
|
|
34
|
-
const id =
|
|
35
|
+
const id = uuidv7();
|
|
35
36
|
const projectId = computeProjectId(projectRoot);
|
|
36
37
|
const now = new Date().toISOString();
|
|
37
38
|
|