Skip to content

Commit 4c0f44c

Browse files
feat: add planet universe and pixel office task views
1 parent 056766f commit 4c0f44c

32 files changed

Lines changed: 6893 additions & 224 deletions

src/server/croc-office.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export class CrocOffice {
8080
private agents: CrocAgent[];
8181
private cachedGraph: KnowledgeGraph | null = null;
8282
private running = false;
83-
private readonly taskStore = new TaskStore();
83+
private readonly taskStore: TaskStore;
8484
private activeTaskId: string | null = null;
8585
private feishuBridge: FeishuProgressBridge | null = null;
8686
private lastPipelineResult: PipelineRunResult | null = null;
@@ -94,10 +94,11 @@ export class CrocOffice {
9494
'thinking',
9595
]);
9696

97-
constructor(config: OpenCrocConfig, cwd: string) {
97+
constructor(config: OpenCrocConfig, cwd: string, options: { taskStore?: TaskStore } = {}) {
9898
this.config = config;
9999
this.cwd = cwd;
100100
this.agents = DEFAULT_AGENTS.map((a) => ({ ...a }));
101+
this.taskStore = options.taskStore ?? new TaskStore();
101102
}
102103

103104
addClient(ws: WebSocket): void {
@@ -128,28 +129,28 @@ export class CrocOffice {
128129
}
129130
}
130131

131-
createTask(kind: string, title: string, stageLabels: Array<{ key: string; label: string }>): TaskRecord {
132-
const task = this.taskStore.create({ kind, title, stageLabels });
132+
createTask(kind: string, title: string, stageLabels: Array<{ key: string; label: string }>, sourceText?: string): TaskRecord {
133+
const task = this.taskStore.create({ kind, title, stageLabels, sourceText });
133134
void this.emitTaskUpdate(task);
134135
return task;
135136
}
136137

137-
createChatTask(title: string): TaskRecord {
138+
createChatTask(title: string, sourceText?: string): TaskRecord {
138139
return this.createTask('chat', title, [
139140
{ key: 'receive', label: 'Receive task' },
140141
{ key: 'understand', label: 'Understand problem' },
141142
{ key: 'gather', label: 'Gather materials / scan context' },
142143
{ key: 'generate', label: 'Generate answer' },
143144
{ key: 'finalize', label: 'Finalize output' },
144-
]);
145+
], sourceText);
145146
}
146147

147-
ensureActiveTask(kind: string, title: string, stageLabels: Array<{ key: string; label: string }>): TaskRecord {
148+
ensureActiveTask(kind: string, title: string, stageLabels: Array<{ key: string; label: string }>, sourceText?: string): TaskRecord {
148149
if (this.activeTaskId) {
149150
const existing = this.taskStore.get(this.activeTaskId);
150151
if (existing) return existing;
151152
}
152-
const task = this.createTask(kind, title, stageLabels);
153+
const task = this.createTask(kind, title, stageLabels, sourceText);
153154
this.activateTask(task.id);
154155
return task;
155156
}

src/server/edge-store.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { mkdtempSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import { tmpdir } from 'node:os';
4+
import { describe, expect, it } from 'vitest';
5+
6+
import { FilePlanetEdgeStore } from './edge-store.js';
7+
8+
describe('FilePlanetEdgeStore', () => {
9+
it('persists manual edges and lets manual values override auto edges', () => {
10+
const dir = mkdtempSync(join(tmpdir(), 'opencroc-edge-store-'));
11+
const filePath = join(dir, 'planet-edges.json');
12+
const store = new FilePlanetEdgeStore(filePath);
13+
14+
store.upsertManual({
15+
fromPlanetId: 'report-1',
16+
toPlanetId: 'scan-1',
17+
type: 'depends-on',
18+
reason: 'manual relationship',
19+
});
20+
21+
const restored = new FilePlanetEdgeStore(filePath);
22+
expect(restored.listManual()).toEqual([
23+
expect.objectContaining({
24+
fromPlanetId: 'report-1',
25+
toPlanetId: 'scan-1',
26+
type: 'depends-on',
27+
source: 'manual',
28+
confidence: 1,
29+
}),
30+
]);
31+
32+
const merged = restored.getMerged([
33+
{
34+
fromPlanetId: 'report-1',
35+
toPlanetId: 'scan-1',
36+
type: 'related-to',
37+
source: 'auto',
38+
confidence: 0.82,
39+
},
40+
{
41+
fromPlanetId: 'analysis-1',
42+
toPlanetId: 'scan-1',
43+
type: 'depends-on',
44+
source: 'auto',
45+
confidence: 0.64,
46+
},
47+
]);
48+
49+
expect(merged).toEqual(expect.arrayContaining([
50+
expect.objectContaining({
51+
fromPlanetId: 'report-1',
52+
toPlanetId: 'scan-1',
53+
type: 'depends-on',
54+
source: 'manual',
55+
}),
56+
expect.objectContaining({
57+
fromPlanetId: 'analysis-1',
58+
toPlanetId: 'scan-1',
59+
type: 'depends-on',
60+
source: 'auto',
61+
}),
62+
]));
63+
});
64+
65+
it('removes manual edges durably', () => {
66+
const dir = mkdtempSync(join(tmpdir(), 'opencroc-edge-remove-'));
67+
const filePath = join(dir, 'planet-edges.json');
68+
const store = new FilePlanetEdgeStore(filePath);
69+
70+
store.upsertManual({
71+
fromPlanetId: 'report-1',
72+
toPlanetId: 'scan-1',
73+
type: 'depends-on',
74+
});
75+
76+
expect(store.removeManual('report-1', 'scan-1')).toBe(true);
77+
78+
const restored = new FilePlanetEdgeStore(filePath);
79+
expect(restored.listManual()).toEqual([]);
80+
});
81+
});

src/server/edge-store.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2+
import { dirname } from 'node:path';
3+
import type { PlanetEdge, PlanetEdgeType } from './planet-edge-inference.js';
4+
5+
export interface PlanetEdgeStore {
6+
listManual(): PlanetEdge[];
7+
upsertManual(edge: { fromPlanetId: string; toPlanetId: string; type: PlanetEdgeType; reason?: string }): PlanetEdge;
8+
removeManual(fromPlanetId: string, toPlanetId: string): boolean;
9+
getMerged(autoEdges: PlanetEdge[]): PlanetEdge[];
10+
}
11+
12+
interface SerializedEdgeFile {
13+
version: 1;
14+
manualEdges: PlanetEdge[];
15+
}
16+
17+
function normalizeEdge(value: unknown): PlanetEdge | null {
18+
if (!value || typeof value !== 'object') return null;
19+
const candidate = value as Partial<PlanetEdge>;
20+
if (
21+
typeof candidate.fromPlanetId !== 'string' ||
22+
typeof candidate.toPlanetId !== 'string' ||
23+
(candidate.type !== 'depends-on' && candidate.type !== 'related-to' && candidate.type !== 'supersedes')
24+
) {
25+
return null;
26+
}
27+
28+
return {
29+
fromPlanetId: candidate.fromPlanetId,
30+
toPlanetId: candidate.toPlanetId,
31+
type: candidate.type,
32+
confidence: typeof candidate.confidence === 'number' ? Math.max(0, Math.min(candidate.confidence, 1)) : 1,
33+
source: candidate.source === 'auto' ? 'auto' : 'manual',
34+
reason: typeof candidate.reason === 'string' ? candidate.reason : undefined,
35+
};
36+
}
37+
38+
function edgeKey(fromPlanetId: string, toPlanetId: string): string {
39+
return `${fromPlanetId}::${toPlanetId}`;
40+
}
41+
42+
export class FilePlanetEdgeStore implements PlanetEdgeStore {
43+
private readonly manualEdges = new Map<string, PlanetEdge>();
44+
45+
constructor(private readonly filePath: string) {
46+
for (const edge of this.readFile()) {
47+
this.manualEdges.set(edgeKey(edge.fromPlanetId, edge.toPlanetId), edge);
48+
}
49+
}
50+
51+
listManual(): PlanetEdge[] {
52+
return [...this.manualEdges.values()].map((edge) => structuredClone(edge));
53+
}
54+
55+
upsertManual(edge: { fromPlanetId: string; toPlanetId: string; type: PlanetEdgeType; reason?: string }): PlanetEdge {
56+
const nextEdge: PlanetEdge = {
57+
fromPlanetId: edge.fromPlanetId,
58+
toPlanetId: edge.toPlanetId,
59+
type: edge.type,
60+
confidence: 1,
61+
source: 'manual',
62+
reason: edge.reason,
63+
};
64+
this.manualEdges.set(edgeKey(nextEdge.fromPlanetId, nextEdge.toPlanetId), nextEdge);
65+
this.persist();
66+
return structuredClone(nextEdge);
67+
}
68+
69+
removeManual(fromPlanetId: string, toPlanetId: string): boolean {
70+
const removed = this.manualEdges.delete(edgeKey(fromPlanetId, toPlanetId));
71+
if (removed) this.persist();
72+
return removed;
73+
}
74+
75+
getMerged(autoEdges: PlanetEdge[]): PlanetEdge[] {
76+
const merged = new Map<string, PlanetEdge>();
77+
78+
for (const edge of autoEdges) {
79+
merged.set(edgeKey(edge.fromPlanetId, edge.toPlanetId), structuredClone(edge));
80+
}
81+
82+
for (const edge of this.manualEdges.values()) {
83+
merged.set(edgeKey(edge.fromPlanetId, edge.toPlanetId), structuredClone(edge));
84+
}
85+
86+
return [...merged.values()].sort((left, right) => {
87+
if (left.source !== right.source) return left.source === 'manual' ? -1 : 1;
88+
return right.confidence - left.confidence;
89+
});
90+
}
91+
92+
private persist(): void {
93+
const data: SerializedEdgeFile = {
94+
version: 1,
95+
manualEdges: [...this.manualEdges.values()]
96+
.map((edge) => normalizeEdge(edge))
97+
.filter((edge): edge is PlanetEdge => Boolean(edge))
98+
.sort((left, right) => edgeKey(left.fromPlanetId, left.toPlanetId).localeCompare(edgeKey(right.fromPlanetId, right.toPlanetId))),
99+
};
100+
101+
mkdirSync(dirname(this.filePath), { recursive: true });
102+
writeFileSync(this.filePath, JSON.stringify(data, null, 2), 'utf-8');
103+
}
104+
105+
private readFile(): PlanetEdge[] {
106+
if (!existsSync(this.filePath)) return [];
107+
108+
try {
109+
const raw = readFileSync(this.filePath, 'utf-8');
110+
const parsed = JSON.parse(raw) as SerializedEdgeFile | PlanetEdge[];
111+
const edges = Array.isArray(parsed)
112+
? parsed
113+
: Array.isArray(parsed.manualEdges)
114+
? parsed.manualEdges
115+
: [];
116+
117+
return edges
118+
.map((edge) => normalizeEdge(edge))
119+
.filter((edge): edge is PlanetEdge => Boolean(edge))
120+
.map((edge) => ({ ...edge, source: 'manual', confidence: 1 }));
121+
} catch {
122+
return [];
123+
}
124+
}
125+
}

src/server/feishu-smoke.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ function wait(ms: number): Promise<void> {
88
export function registerFeishuSmokeRoutes(app: FastifyInstance, office: CrocOffice): void {
99
app.post<{ Body: { chatId: string; requestId?: string; title?: string; mode?: 'text' | 'card' } }>('/api/feishu/smoke/progress', async (req, reply) => {
1010
const title = req.body.title || 'Feishu progress smoke test';
11-
const task = office.createChatTask(title);
11+
const task = office.createChatTask(title, title);
1212

1313
office.bindTaskToFeishu(task.id, {
1414
chatId: req.body.chatId,

src/server/feishu-task-start.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export async function startComplexFeishuChatTask(
7373
feishuBridge: FeishuProgressBridge,
7474
params: FeishuComplexTaskStartParams,
7575
): Promise<FeishuComplexTaskStartOutcome> {
76-
const task = office.createChatTask(buildTitle(params.text));
76+
const task = office.createChatTask(buildTitle(params.text), params.text);
7777
office.bindTaskToFeishu(task.id, {
7878
chatId: params.chatId,
7979
threadId: params.threadId,

src/server/index.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { FilePlanetEdgeStore } from './edge-store.js';
12
import Fastify from 'fastify';
23
import fastifyStatic from '@fastify/static';
34
import fastifyWebsocket from '@fastify/websocket';
@@ -6,9 +7,13 @@ import { dirname, join, resolve } from 'node:path';
67
import { existsSync } from 'node:fs';
78
import { registerProjectRoutes } from './routes/project.js';
89
import { registerAgentRoutes } from './routes/agents.js';
10+
import { registerPlanetRoutes } from './routes/planets.js';
911
import { registerStudioRoutes } from './routes/studio.js';
1012
import { CrocOffice } from './croc-office.js';
13+
import { FilePlanetMetaStore } from './planet-meta-store.js';
1114
import { FileStudioSnapshotStore } from './studio-store.js';
15+
import { FileTaskSnapshotStore } from './task-store.file.js';
16+
import { TaskStore } from './task-store.js';
1217
import { FeishuProgressBridge } from './feishu-bridge.js';
1318
import { FeishuApiDelivery } from './feishu-delivery.js';
1419
import { registerFeishuIngressRoutes } from './feishu-ingress.js';
@@ -62,7 +67,12 @@ export async function startServer(opts: ServeOptions): Promise<void> {
6267
}
6368

6469
// --- Croc Office (Agent orchestrator) ---
65-
const office = new CrocOffice(opts.config, opts.cwd);
70+
const taskSnapshotStore = new FileTaskSnapshotStore(resolve(opts.cwd, '.opencroc/task-snapshots.json'));
71+
const planetMetaStore = new FilePlanetMetaStore(resolve(opts.cwd, '.opencroc/planet-meta.json'));
72+
const edgeStore = new FilePlanetEdgeStore(resolve(opts.cwd, '.opencroc/planet-edges.json'));
73+
const office = new CrocOffice(opts.config, opts.cwd, {
74+
taskStore: new TaskStore(taskSnapshotStore),
75+
});
6676
const snapshotStore = new FileStudioSnapshotStore(resolve(opts.cwd, '.opencroc/studio-snapshot.json'));
6777
const feishuConfig = {
6878
...(opts.config.feishu ?? {}),
@@ -76,6 +86,7 @@ export async function startServer(opts: ServeOptions): Promise<void> {
7686
// --- REST API routes ---
7787
registerProjectRoutes(app, office);
7888
registerAgentRoutes(app, office);
89+
registerPlanetRoutes(app, office, planetMetaStore, edgeStore);
7990
registerStudioRoutes(app, office, snapshotStore);
8091
registerFeishuIngressRoutes(app, office, feishuBridge);
8192
registerFeishuRelayRoutes(app, office, feishuBridge);
@@ -117,6 +128,10 @@ export async function startServer(opts: ServeOptions): Promise<void> {
117128
return sendSpaEntry(reply);
118129
});
119130

131+
app.get('/universe', (_req, reply) => {
132+
return sendSpaEntry(reply);
133+
});
134+
120135
// --- SPA fallback: serve index.html for non-API, non-asset routes ---
121136
app.setNotFoundHandler((req, reply) => {
122137
if (req.url.startsWith('/api/') || req.url.startsWith('/ws') || isAssetRequest(req.url)) {

0 commit comments

Comments
 (0)