@parallel-cli/parallel 0.4.8 → 0.5.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/CHANGELOG.md +57 -0
- package/README.md +43 -7
- package/dist/agents/agent.js +213 -31
- package/dist/agents/execution-policy.js +58 -0
- package/dist/agents/tools.js +83 -22
- package/dist/commands.js +67 -5
- package/dist/config.js +5 -2
- package/dist/controller.js +229 -11
- package/dist/coordination/blackboard.js +8 -7
- package/dist/diagnostics.js +209 -0
- package/dist/i18n.js +60 -4
- package/dist/index.js +31 -7
- package/dist/llm/client.js +7 -3
- package/dist/project-context.js +477 -0
- package/dist/project-index.js +186 -0
- package/dist/security.js +93 -0
- package/dist/server.js +41 -2
- package/dist/ui/AgentPanel.js +6 -2
- package/dist/ui/App.js +4 -2
- package/dist/ui/AttachApp.js +6 -3
- package/dist/ui/CommandInput.js +9 -0
- package/dist/ui/SettingsPanel.js +22 -23
- package/dist/ui/Timeline.js +3 -0
- package/dist/ui/Wizard.js +49 -21
- package/dist/ui/events.js +4 -0
- package/dist/ui/views.js +4 -2
- package/dist/update.js +3 -2
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { redactPersistedText, sanitizeForPersistence, writeFileAtomicPrivate } from './security.js';
|
|
6
|
+
const SCHEMA_VERSION = 1;
|
|
7
|
+
const MAX_CONTEXT_CHARS = 12_000;
|
|
8
|
+
const MAX_SEED_CHARS = 28_000;
|
|
9
|
+
const IGNORED = new Set([
|
|
10
|
+
'.git',
|
|
11
|
+
'.parallel',
|
|
12
|
+
'.cursor',
|
|
13
|
+
'node_modules',
|
|
14
|
+
'dist',
|
|
15
|
+
'build',
|
|
16
|
+
'coverage',
|
|
17
|
+
'.next',
|
|
18
|
+
'__pycache__',
|
|
19
|
+
'.venv',
|
|
20
|
+
'venv',
|
|
21
|
+
]);
|
|
22
|
+
const SEED_FILES = [
|
|
23
|
+
'AGENTS.md',
|
|
24
|
+
'README.md',
|
|
25
|
+
'CHANGELOG.md',
|
|
26
|
+
'package.json',
|
|
27
|
+
'pyproject.toml',
|
|
28
|
+
'Cargo.toml',
|
|
29
|
+
'go.mod',
|
|
30
|
+
'Makefile',
|
|
31
|
+
'tsconfig.json',
|
|
32
|
+
];
|
|
33
|
+
function ignoredName(name) {
|
|
34
|
+
const lower = name.toLowerCase();
|
|
35
|
+
return (IGNORED.has(name) ||
|
|
36
|
+
lower === '.env' ||
|
|
37
|
+
lower.startsWith('.env.') ||
|
|
38
|
+
/(^|[._-])(secret|secrets|credential|credentials|private-key|id_rsa)([._-]|$)/.test(lower));
|
|
39
|
+
}
|
|
40
|
+
function hashText(content) {
|
|
41
|
+
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
42
|
+
}
|
|
43
|
+
function safeJsonObject(text) {
|
|
44
|
+
const trimmed = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(trimmed);
|
|
47
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function stringArray(value, max) {
|
|
54
|
+
if (!Array.isArray(value))
|
|
55
|
+
return [];
|
|
56
|
+
return value
|
|
57
|
+
.map((item) => String(item ?? '').replace(/\s+/g, ' ').trim())
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.slice(0, max);
|
|
60
|
+
}
|
|
61
|
+
function safeRelativePath(value) {
|
|
62
|
+
const rel = String(value ?? '').replace(/\\/g, '/').replace(/^\.\/+/, '');
|
|
63
|
+
if (!rel || path.isAbsolute(rel) || rel === '..' || rel.startsWith('../'))
|
|
64
|
+
return null;
|
|
65
|
+
return rel;
|
|
66
|
+
}
|
|
67
|
+
function contextFiles(value) {
|
|
68
|
+
if (!Array.isArray(value))
|
|
69
|
+
return [];
|
|
70
|
+
const files = [];
|
|
71
|
+
for (const item of value.slice(-200)) {
|
|
72
|
+
if (!item || typeof item !== 'object')
|
|
73
|
+
continue;
|
|
74
|
+
const raw = item;
|
|
75
|
+
const filePath = safeRelativePath(raw.path);
|
|
76
|
+
const hash = String(raw.hash ?? '');
|
|
77
|
+
if (!filePath || !/^[a-f0-9]{16}$/.test(hash))
|
|
78
|
+
continue;
|
|
79
|
+
files.push({ path: filePath, hash, inspectedAt: String(raw.inspectedAt ?? '') });
|
|
80
|
+
}
|
|
81
|
+
return files;
|
|
82
|
+
}
|
|
83
|
+
function contextWork(value) {
|
|
84
|
+
if (!Array.isArray(value))
|
|
85
|
+
return [];
|
|
86
|
+
const work = [];
|
|
87
|
+
for (const item of value.slice(-20)) {
|
|
88
|
+
if (!item || typeof item !== 'object')
|
|
89
|
+
continue;
|
|
90
|
+
const raw = item;
|
|
91
|
+
const task = String(raw.task ?? '').trim();
|
|
92
|
+
const result = String(raw.result ?? '').trim();
|
|
93
|
+
if (!task || !result)
|
|
94
|
+
continue;
|
|
95
|
+
work.push({
|
|
96
|
+
agentName: String(raw.agentName ?? 'agent').slice(0, 80),
|
|
97
|
+
task: task.slice(0, 2_000),
|
|
98
|
+
result: result.slice(0, 5_000),
|
|
99
|
+
inspectedFiles: stringArray(raw.inspectedFiles, 100).flatMap((file) => {
|
|
100
|
+
const safe = safeRelativePath(file);
|
|
101
|
+
return safe ? [safe] : [];
|
|
102
|
+
}),
|
|
103
|
+
changedFiles: stringArray(raw.changedFiles, 100).flatMap((file) => {
|
|
104
|
+
const safe = safeRelativePath(file);
|
|
105
|
+
return safe ? [safe] : [];
|
|
106
|
+
}),
|
|
107
|
+
completedAt: String(raw.completedAt ?? ''),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return work;
|
|
111
|
+
}
|
|
112
|
+
export class ProjectContextStore {
|
|
113
|
+
projectRoot;
|
|
114
|
+
onStatus;
|
|
115
|
+
data = null;
|
|
116
|
+
status = 'idle';
|
|
117
|
+
generation = null;
|
|
118
|
+
refreshTimer = null;
|
|
119
|
+
inspectedByAgent = new Map();
|
|
120
|
+
error;
|
|
121
|
+
constructor(projectRoot, onStatus) {
|
|
122
|
+
this.projectRoot = projectRoot;
|
|
123
|
+
this.onStatus = onStatus;
|
|
124
|
+
this.data = this.load();
|
|
125
|
+
if (this.data)
|
|
126
|
+
this.status = this.data.fingerprint === this.fingerprint() ? 'ready' : 'idle';
|
|
127
|
+
}
|
|
128
|
+
file() {
|
|
129
|
+
return path.join(this.projectRoot, '.parallel', 'project-context.json');
|
|
130
|
+
}
|
|
131
|
+
load() {
|
|
132
|
+
try {
|
|
133
|
+
const raw = JSON.parse(fs.readFileSync(this.file(), 'utf8'));
|
|
134
|
+
if (raw.schemaVersion !== SCHEMA_VERSION || raw.projectRoot !== this.projectRoot)
|
|
135
|
+
return null;
|
|
136
|
+
if (typeof raw.architecture !== 'string' || typeof raw.fingerprint !== 'string')
|
|
137
|
+
return null;
|
|
138
|
+
return {
|
|
139
|
+
schemaVersion: SCHEMA_VERSION,
|
|
140
|
+
generatedAt: String(raw.generatedAt ?? ''),
|
|
141
|
+
projectRoot: this.projectRoot,
|
|
142
|
+
gitHead: String(raw.gitHead ?? ''),
|
|
143
|
+
fingerprint: raw.fingerprint,
|
|
144
|
+
model: raw.model,
|
|
145
|
+
architecture: raw.architecture,
|
|
146
|
+
entryPoints: stringArray(raw.entryPoints, 20),
|
|
147
|
+
conventions: stringArray(raw.conventions, 20),
|
|
148
|
+
pitfalls: stringArray(raw.pitfalls, 20),
|
|
149
|
+
files: contextFiles(raw.files),
|
|
150
|
+
recentWork: contextWork(raw.recentWork),
|
|
151
|
+
deterministicSeed: String(raw.deterministicSeed ?? ''),
|
|
152
|
+
tokensIn: Number(raw.tokensIn ?? 0),
|
|
153
|
+
tokensOut: Number(raw.tokensOut ?? 0),
|
|
154
|
+
cost: raw.cost === null ? null : Number(raw.cost ?? 0),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
persist() {
|
|
162
|
+
if (!this.data)
|
|
163
|
+
return;
|
|
164
|
+
writeFileAtomicPrivate(this.file(), sanitizeForPersistence(JSON.stringify(this.data, null, 2)));
|
|
165
|
+
}
|
|
166
|
+
gitHead() {
|
|
167
|
+
try {
|
|
168
|
+
return String(execFileSync('git', ['rev-parse', 'HEAD'], { cwd: this.projectRoot, stdio: 'pipe' })).trim();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return '';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
fingerprint() {
|
|
175
|
+
let worktree = '';
|
|
176
|
+
try {
|
|
177
|
+
worktree = String(execFileSync('git', ['diff', '--no-ext-diff', '--binary', '--', '.'], {
|
|
178
|
+
cwd: this.projectRoot,
|
|
179
|
+
stdio: 'pipe',
|
|
180
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
catch { }
|
|
184
|
+
const untrackedBits = [];
|
|
185
|
+
try {
|
|
186
|
+
const files = String(execFileSync('git', ['ls-files', '--others', '--exclude-standard'], {
|
|
187
|
+
cwd: this.projectRoot,
|
|
188
|
+
stdio: 'pipe',
|
|
189
|
+
}))
|
|
190
|
+
.split('\n')
|
|
191
|
+
.filter(Boolean)
|
|
192
|
+
.filter((rel) => !rel.split('/').some(ignoredName))
|
|
193
|
+
.slice(0, 100);
|
|
194
|
+
for (const rel of files) {
|
|
195
|
+
try {
|
|
196
|
+
const content = fs.readFileSync(path.join(this.projectRoot, rel));
|
|
197
|
+
untrackedBits.push(`${rel}:${hashText(content.subarray(0, 200_000).toString('binary'))}`);
|
|
198
|
+
}
|
|
199
|
+
catch { }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch { }
|
|
203
|
+
const manifestBits = [];
|
|
204
|
+
for (const rel of SEED_FILES) {
|
|
205
|
+
try {
|
|
206
|
+
const content = fs.readFileSync(path.join(this.projectRoot, rel), 'utf8');
|
|
207
|
+
manifestBits.push(`${rel}:${hashText(content)}`);
|
|
208
|
+
}
|
|
209
|
+
catch { }
|
|
210
|
+
}
|
|
211
|
+
return hashText([this.gitHead(), worktree, ...untrackedBits, ...manifestBits].join('\n'));
|
|
212
|
+
}
|
|
213
|
+
projectTree() {
|
|
214
|
+
const out = [];
|
|
215
|
+
const walk = (dir, depth) => {
|
|
216
|
+
if (depth > 5 || out.length >= 400)
|
|
217
|
+
return;
|
|
218
|
+
let entries;
|
|
219
|
+
try {
|
|
220
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
226
|
+
if (ignoredName(entry.name) || entry.name.startsWith('.git'))
|
|
227
|
+
continue;
|
|
228
|
+
const full = path.join(dir, entry.name);
|
|
229
|
+
const rel = path.relative(this.projectRoot, full);
|
|
230
|
+
if (entry.isDirectory()) {
|
|
231
|
+
out.push(`${rel}/`);
|
|
232
|
+
walk(full, depth + 1);
|
|
233
|
+
}
|
|
234
|
+
else if (entry.isFile()) {
|
|
235
|
+
out.push(rel);
|
|
236
|
+
}
|
|
237
|
+
if (out.length >= 400)
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
walk(this.projectRoot, 0);
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
deterministicSeed() {
|
|
245
|
+
const sections = [
|
|
246
|
+
`PROJECT ROOT\n${this.projectRoot}`,
|
|
247
|
+
`GIT HEAD\n${this.gitHead() || '(not a git repository)'}`,
|
|
248
|
+
`PROJECT TREE\n${this.projectTree().join('\n')}`,
|
|
249
|
+
];
|
|
250
|
+
for (const rel of SEED_FILES) {
|
|
251
|
+
try {
|
|
252
|
+
const content = fs.readFileSync(path.join(this.projectRoot, rel), 'utf8').slice(0, 8_000);
|
|
253
|
+
sections.push(`${rel}\n${redactPersistedText(content)}`);
|
|
254
|
+
}
|
|
255
|
+
catch { }
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
const memory = fs.readFileSync(path.join(this.projectRoot, '.parallel', 'memory.md'), 'utf8').slice(-8_000);
|
|
259
|
+
sections.push(`DURABLE USER/AGENT FACTS\n${redactPersistedText(memory)}`);
|
|
260
|
+
}
|
|
261
|
+
catch { }
|
|
262
|
+
if (this.data?.recentWork.length) {
|
|
263
|
+
sections.push(`RECENT WORK\n${redactPersistedText(this.data.recentWork
|
|
264
|
+
.slice(-10)
|
|
265
|
+
.map((work) => `${work.agentName}: ${work.task}\n${work.result}\nfiles: ${[...work.inspectedFiles, ...work.changedFiles].join(', ')}`)
|
|
266
|
+
.join('\n\n'))}`);
|
|
267
|
+
}
|
|
268
|
+
return sections.join('\n\n---\n\n').slice(0, MAX_SEED_CHARS);
|
|
269
|
+
}
|
|
270
|
+
statusSnapshot() {
|
|
271
|
+
return {
|
|
272
|
+
status: this.status,
|
|
273
|
+
generatedAt: this.data?.generatedAt,
|
|
274
|
+
fingerprint: this.data?.fingerprint ?? this.fingerprint(),
|
|
275
|
+
model: this.data?.model,
|
|
276
|
+
tokensIn: this.data?.tokensIn ?? 0,
|
|
277
|
+
tokensOut: this.data?.tokensOut ?? 0,
|
|
278
|
+
cost: this.data?.cost ?? null,
|
|
279
|
+
error: this.error,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
recordInspection(agentId, relPath, content) {
|
|
283
|
+
const normalized = relPath.replace(/\\/g, '/');
|
|
284
|
+
let files = this.inspectedByAgent.get(agentId);
|
|
285
|
+
if (!files) {
|
|
286
|
+
files = new Map();
|
|
287
|
+
this.inspectedByAgent.set(agentId, files);
|
|
288
|
+
}
|
|
289
|
+
files.set(normalized, {
|
|
290
|
+
path: normalized,
|
|
291
|
+
hash: hashText(content),
|
|
292
|
+
inspectedAt: new Date().toISOString(),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
inspectedFiles(agentId) {
|
|
296
|
+
return [...(this.inspectedByAgent.get(agentId)?.keys() ?? [])].sort();
|
|
297
|
+
}
|
|
298
|
+
recordOutcome(agentId, work) {
|
|
299
|
+
const inspected = [...(this.inspectedByAgent.get(agentId)?.values() ?? [])];
|
|
300
|
+
const base = this.data ?? this.fallbackData();
|
|
301
|
+
const byPath = new Map(base.files.map((file) => [file.path, file]));
|
|
302
|
+
for (const file of inspected)
|
|
303
|
+
byPath.set(file.path, file);
|
|
304
|
+
base.files = [...byPath.values()].slice(-200);
|
|
305
|
+
base.recentWork = [
|
|
306
|
+
...base.recentWork,
|
|
307
|
+
{
|
|
308
|
+
...work,
|
|
309
|
+
inspectedFiles: inspected.map((file) => file.path),
|
|
310
|
+
completedAt: new Date().toISOString(),
|
|
311
|
+
},
|
|
312
|
+
].slice(-20);
|
|
313
|
+
base.fingerprint = this.fingerprint();
|
|
314
|
+
base.gitHead = this.gitHead();
|
|
315
|
+
base.generatedAt = new Date().toISOString();
|
|
316
|
+
base.deterministicSeed = this.deterministicSeed();
|
|
317
|
+
this.data = base;
|
|
318
|
+
this.persist();
|
|
319
|
+
}
|
|
320
|
+
fallbackData() {
|
|
321
|
+
const seed = this.deterministicSeed();
|
|
322
|
+
return {
|
|
323
|
+
schemaVersion: SCHEMA_VERSION,
|
|
324
|
+
generatedAt: new Date().toISOString(),
|
|
325
|
+
projectRoot: this.projectRoot,
|
|
326
|
+
gitHead: this.gitHead(),
|
|
327
|
+
fingerprint: this.fingerprint(),
|
|
328
|
+
architecture: 'No validated architecture summary is available yet. Use the project tree and targeted file reads.',
|
|
329
|
+
entryPoints: [],
|
|
330
|
+
conventions: [],
|
|
331
|
+
pitfalls: [],
|
|
332
|
+
files: this.data?.files ?? [],
|
|
333
|
+
recentWork: this.data?.recentWork ?? [],
|
|
334
|
+
deterministicSeed: seed,
|
|
335
|
+
tokensIn: 0,
|
|
336
|
+
tokensOut: 0,
|
|
337
|
+
cost: null,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
async refresh(generator, force = false) {
|
|
341
|
+
const currentFingerprint = this.fingerprint();
|
|
342
|
+
if (!force && this.data?.fingerprint === currentFingerprint && this.status === 'ready')
|
|
343
|
+
return this.data;
|
|
344
|
+
if (this.generation)
|
|
345
|
+
return this.generation;
|
|
346
|
+
this.status = 'indexing';
|
|
347
|
+
this.error = undefined;
|
|
348
|
+
this.onStatus?.('indexing');
|
|
349
|
+
this.generation = (async () => {
|
|
350
|
+
const seed = this.deterministicSeed();
|
|
351
|
+
if (!generator) {
|
|
352
|
+
this.data = this.fallbackData();
|
|
353
|
+
this.status = 'fallback';
|
|
354
|
+
this.onStatus?.('fallback');
|
|
355
|
+
this.persist();
|
|
356
|
+
return this.data;
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
const response = await generator([
|
|
360
|
+
{
|
|
361
|
+
role: 'system',
|
|
362
|
+
content: 'Build a compact, factual software-project map. Return ONLY JSON with keys architecture (string), entryPoints (string[]), conventions (string[]), pitfalls (string[]). Do not include secrets, credentials, prose outside JSON, or guesses not grounded in the supplied repository seed.',
|
|
363
|
+
},
|
|
364
|
+
{ role: 'user', content: seed },
|
|
365
|
+
]);
|
|
366
|
+
const parsed = safeJsonObject(response.content);
|
|
367
|
+
if (!parsed || typeof parsed.architecture !== 'string')
|
|
368
|
+
throw new Error('invalid project-context JSON');
|
|
369
|
+
this.data = {
|
|
370
|
+
schemaVersion: SCHEMA_VERSION,
|
|
371
|
+
generatedAt: new Date().toISOString(),
|
|
372
|
+
projectRoot: this.projectRoot,
|
|
373
|
+
gitHead: this.gitHead(),
|
|
374
|
+
fingerprint: this.fingerprint(),
|
|
375
|
+
model: response.model,
|
|
376
|
+
architecture: parsed.architecture.slice(0, 5_000),
|
|
377
|
+
entryPoints: stringArray(parsed.entryPoints, 20),
|
|
378
|
+
conventions: stringArray(parsed.conventions, 20),
|
|
379
|
+
pitfalls: stringArray(parsed.pitfalls, 20),
|
|
380
|
+
files: this.data?.files ?? [],
|
|
381
|
+
recentWork: this.data?.recentWork ?? [],
|
|
382
|
+
deterministicSeed: seed,
|
|
383
|
+
tokensIn: response.tokensIn,
|
|
384
|
+
tokensOut: response.tokensOut,
|
|
385
|
+
cost: response.cost,
|
|
386
|
+
};
|
|
387
|
+
this.status = 'ready';
|
|
388
|
+
this.onStatus?.('ready');
|
|
389
|
+
this.persist();
|
|
390
|
+
return this.data;
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
this.error = String(error?.message ?? error).slice(0, 200);
|
|
394
|
+
this.data = this.fallbackData();
|
|
395
|
+
this.status = 'fallback';
|
|
396
|
+
this.onStatus?.('fallback', this.error);
|
|
397
|
+
this.persist();
|
|
398
|
+
return this.data;
|
|
399
|
+
}
|
|
400
|
+
finally {
|
|
401
|
+
this.generation = null;
|
|
402
|
+
}
|
|
403
|
+
})();
|
|
404
|
+
return this.generation;
|
|
405
|
+
}
|
|
406
|
+
scheduleRefresh(generator) {
|
|
407
|
+
if (this.refreshTimer)
|
|
408
|
+
clearTimeout(this.refreshTimer);
|
|
409
|
+
this.refreshTimer = setTimeout(() => {
|
|
410
|
+
this.refreshTimer = null;
|
|
411
|
+
void this.refresh(generator, true);
|
|
412
|
+
}, 750);
|
|
413
|
+
this.refreshTimer.unref?.();
|
|
414
|
+
}
|
|
415
|
+
async bootstrap(generator, timeoutMs = 20_000) {
|
|
416
|
+
const refresh = this.refresh(generator);
|
|
417
|
+
let timer;
|
|
418
|
+
try {
|
|
419
|
+
const data = await Promise.race([
|
|
420
|
+
refresh,
|
|
421
|
+
new Promise((resolve) => {
|
|
422
|
+
timer = setTimeout(() => resolve(this.data ?? this.fallbackData()), timeoutMs);
|
|
423
|
+
}),
|
|
424
|
+
]);
|
|
425
|
+
return this.format(data);
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
if (timer)
|
|
429
|
+
clearTimeout(timer);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
snapshot() {
|
|
433
|
+
return this.format();
|
|
434
|
+
}
|
|
435
|
+
format(data = this.data ?? this.fallbackData()) {
|
|
436
|
+
const currentFingerprint = this.fingerprint();
|
|
437
|
+
const stale = data.fingerprint !== currentFingerprint;
|
|
438
|
+
const knownFiles = data.files
|
|
439
|
+
.slice(-80)
|
|
440
|
+
.map((file) => {
|
|
441
|
+
try {
|
|
442
|
+
const current = hashText(fs.readFileSync(path.join(this.projectRoot, file.path), 'utf8'));
|
|
443
|
+
return `- ${file.path} [${current === file.hash ? 'fresh' : 'STALE: re-read before relying on it'}]`;
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
return `- ${file.path} [missing or unreadable]`;
|
|
447
|
+
}
|
|
448
|
+
})
|
|
449
|
+
.join('\n');
|
|
450
|
+
const recent = data.recentWork
|
|
451
|
+
.slice(-8)
|
|
452
|
+
.map((work) => `- ${work.agentName}: ${work.task}\n result: ${work.result.slice(0, 800)}\n inspected: ${work.inspectedFiles.join(', ') || '(none)'}\n changed: ${work.changedFiles.join(', ') || '(none)'}`)
|
|
453
|
+
.join('\n');
|
|
454
|
+
return `PROJECT CONTEXT v${data.schemaVersion}
|
|
455
|
+
Generated: ${data.generatedAt}
|
|
456
|
+
Freshness: ${stale ? 'STALE globally — verify task-relevant files' : 'current project fingerprint'}
|
|
457
|
+
Architecture:
|
|
458
|
+
${data.architecture}
|
|
459
|
+
|
|
460
|
+
Entry points:
|
|
461
|
+
${data.entryPoints.map((item) => `- ${item}`).join('\n') || '- unknown'}
|
|
462
|
+
|
|
463
|
+
Conventions:
|
|
464
|
+
${data.conventions.map((item) => `- ${item}`).join('\n') || '- none recorded'}
|
|
465
|
+
|
|
466
|
+
Pitfalls:
|
|
467
|
+
${data.pitfalls.map((item) => `- ${item}`).join('\n') || '- none recorded'}
|
|
468
|
+
|
|
469
|
+
Known inspected files:
|
|
470
|
+
${knownFiles || '- none recorded'}
|
|
471
|
+
|
|
472
|
+
Recent completed work:
|
|
473
|
+
${recent || '- none recorded'}
|
|
474
|
+
|
|
475
|
+
Use this map to orient yourself. Do not perform a generic repository exploration when it already answers the architecture question. Re-read only task-relevant files that are unknown, stale, or about to be modified.`.slice(0, MAX_CONTEXT_CHARS);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { writeFileAtomicPrivate } from './security.js';
|
|
5
|
+
const INDEX_VERSION = 1;
|
|
6
|
+
const IGNORED = new Set(['.git', '.parallel', '.cursor', 'node_modules', 'dist', 'build', 'coverage', '.next']);
|
|
7
|
+
const TEXT_EXTENSIONS = new Set([
|
|
8
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.md', '.py', '.rs', '.go', '.java', '.kt', '.sh', '.yaml', '.yml', '.toml',
|
|
9
|
+
]);
|
|
10
|
+
function hash(content) {
|
|
11
|
+
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
12
|
+
}
|
|
13
|
+
function safeTextFile(name, size) {
|
|
14
|
+
const lower = name.toLowerCase();
|
|
15
|
+
const sensitive = lower === '.env' || lower.startsWith('.env.') || /(^|[._-])(secret|credential|private-key|id_rsa)([._-]|$)/.test(lower);
|
|
16
|
+
return !sensitive && size <= 750_000 && TEXT_EXTENSIONS.has(path.extname(name).toLowerCase());
|
|
17
|
+
}
|
|
18
|
+
function symbolsOf(content) {
|
|
19
|
+
const out = [];
|
|
20
|
+
const patterns = [
|
|
21
|
+
{ kind: 'class', re: /\bclass\s+([A-Za-z_$][\w$]*)/g },
|
|
22
|
+
{ kind: 'function', re: /\b(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g },
|
|
23
|
+
{ kind: 'type', re: /\b(?:interface|type|enum)\s+([A-Za-z_$][\w$]*)/g },
|
|
24
|
+
{ kind: 'export', re: /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g },
|
|
25
|
+
{ kind: 'method', re: /(?:^|[;{}]\s*)(?:private\s+|public\s+|protected\s+|static\s+|async\s+)*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*[:{]/gm },
|
|
26
|
+
];
|
|
27
|
+
for (const { kind, re } of patterns) {
|
|
28
|
+
for (const match of content.matchAll(re)) {
|
|
29
|
+
const index = match.index ?? 0;
|
|
30
|
+
out.push({ name: match[1], line: content.slice(0, index).split('\n').length, kind });
|
|
31
|
+
if (out.length >= 200)
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function importsOf(content) {
|
|
38
|
+
const imports = new Set();
|
|
39
|
+
for (const match of content.matchAll(/\b(?:from\s+|import\s*\(|require\s*\()\s*['"]([^'"]+)['"]/g))
|
|
40
|
+
imports.add(match[1]);
|
|
41
|
+
return [...imports].slice(0, 100);
|
|
42
|
+
}
|
|
43
|
+
function termsOf(content, relPath) {
|
|
44
|
+
const terms = new Set();
|
|
45
|
+
const source = `${relPath} ${content.slice(0, 250_000)}`;
|
|
46
|
+
for (const term of source.match(/[A-Za-z_$][A-Za-z0-9_$-]{2,}/g) ?? []) {
|
|
47
|
+
if (term.length <= 40)
|
|
48
|
+
terms.add(term.toLowerCase());
|
|
49
|
+
if (terms.size >= 2_000)
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
return [...terms];
|
|
53
|
+
}
|
|
54
|
+
export class ProjectIndex {
|
|
55
|
+
projectRoot;
|
|
56
|
+
data = { version: INDEX_VERSION, generatedAt: '', files: [] };
|
|
57
|
+
constructor(projectRoot) {
|
|
58
|
+
this.projectRoot = projectRoot;
|
|
59
|
+
this.load();
|
|
60
|
+
}
|
|
61
|
+
file() {
|
|
62
|
+
return path.join(this.projectRoot, '.parallel', 'index', 'manifest.json');
|
|
63
|
+
}
|
|
64
|
+
load() {
|
|
65
|
+
try {
|
|
66
|
+
const raw = JSON.parse(fs.readFileSync(this.file(), 'utf8'));
|
|
67
|
+
if (raw.version === INDEX_VERSION && Array.isArray(raw.files))
|
|
68
|
+
this.data = raw;
|
|
69
|
+
}
|
|
70
|
+
catch { }
|
|
71
|
+
}
|
|
72
|
+
paths() {
|
|
73
|
+
const out = [];
|
|
74
|
+
const walk = (dir, depth) => {
|
|
75
|
+
if (depth > 10 || out.length > 10_000)
|
|
76
|
+
return;
|
|
77
|
+
let entries;
|
|
78
|
+
try {
|
|
79
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
if (IGNORED.has(entry.name) || entry.name.startsWith('.env'))
|
|
86
|
+
continue;
|
|
87
|
+
const full = path.join(dir, entry.name);
|
|
88
|
+
if (entry.isDirectory())
|
|
89
|
+
walk(full, depth + 1);
|
|
90
|
+
else if (entry.isFile()) {
|
|
91
|
+
try {
|
|
92
|
+
if (safeTextFile(entry.name, fs.statSync(full).size))
|
|
93
|
+
out.push(path.relative(this.projectRoot, full));
|
|
94
|
+
}
|
|
95
|
+
catch { }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
walk(this.projectRoot, 0);
|
|
100
|
+
return out.sort();
|
|
101
|
+
}
|
|
102
|
+
refresh() {
|
|
103
|
+
const previous = new Map(this.data.files.map((file) => [file.path, file]));
|
|
104
|
+
const files = [];
|
|
105
|
+
for (const relPath of this.paths()) {
|
|
106
|
+
try {
|
|
107
|
+
const absolute = path.join(this.projectRoot, relPath);
|
|
108
|
+
const stat = fs.statSync(absolute);
|
|
109
|
+
const old = previous.get(relPath);
|
|
110
|
+
if (old?.size === stat.size && old.mtimeMs === stat.mtimeMs) {
|
|
111
|
+
files.push(old);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const content = fs.readFileSync(absolute, 'utf8');
|
|
115
|
+
const currentHash = hash(content);
|
|
116
|
+
if (old?.hash === currentHash) {
|
|
117
|
+
files.push({ ...old, mtimeMs: stat.mtimeMs, size: stat.size });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
files.push({
|
|
121
|
+
path: relPath,
|
|
122
|
+
hash: currentHash,
|
|
123
|
+
size: stat.size,
|
|
124
|
+
mtimeMs: stat.mtimeMs,
|
|
125
|
+
symbols: symbolsOf(content),
|
|
126
|
+
imports: importsOf(content),
|
|
127
|
+
terms: termsOf(content, relPath),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch { }
|
|
131
|
+
}
|
|
132
|
+
this.data = { version: INDEX_VERSION, generatedAt: new Date().toISOString(), files };
|
|
133
|
+
writeFileAtomicPrivate(this.file(), JSON.stringify(this.data));
|
|
134
|
+
return this.status();
|
|
135
|
+
}
|
|
136
|
+
status() {
|
|
137
|
+
return {
|
|
138
|
+
files: this.data.files.length,
|
|
139
|
+
symbols: this.data.files.reduce((sum, file) => sum + file.symbols.length, 0),
|
|
140
|
+
generatedAt: this.data.generatedAt || undefined,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
retrieve(task = '', limit = 8) {
|
|
144
|
+
this.refresh();
|
|
145
|
+
const stop = new Set(['the', 'and', 'for', 'with', 'why', 'how', 'does', 'this', 'that', 'dans', 'avec', 'pour', 'une', 'les', 'des']);
|
|
146
|
+
const query = new Set((task.toLowerCase().match(/[a-z_$][a-z0-9_$-]{2,}/g) ?? []).filter((term) => !stop.has(term)).slice(0, 80));
|
|
147
|
+
const ranked = this.data.files
|
|
148
|
+
.map((file) => {
|
|
149
|
+
let score = 0;
|
|
150
|
+
const lowerPath = file.path.toLowerCase();
|
|
151
|
+
for (const term of query) {
|
|
152
|
+
if (lowerPath.includes(term))
|
|
153
|
+
score += 8;
|
|
154
|
+
if (file.symbols.some((symbol) => symbol.name.toLowerCase().includes(term)))
|
|
155
|
+
score += 6;
|
|
156
|
+
if (file.terms.includes(term))
|
|
157
|
+
score += 1;
|
|
158
|
+
}
|
|
159
|
+
return { file, score };
|
|
160
|
+
})
|
|
161
|
+
.filter((item) => item.score > 0)
|
|
162
|
+
.sort((a, b) => b.score - a.score || a.file.path.localeCompare(b.file.path))
|
|
163
|
+
.slice(0, limit);
|
|
164
|
+
if (ranked.length === 0)
|
|
165
|
+
return 'Task-oriented index matches: none. Use one bounded search.';
|
|
166
|
+
const lines = ranked.map(({ file, score }) => {
|
|
167
|
+
const symbols = file.symbols.slice(0, 8).map((symbol) => `${symbol.name}@${symbol.line}`).join(', ');
|
|
168
|
+
let matches = '';
|
|
169
|
+
try {
|
|
170
|
+
const contentLines = fs.readFileSync(path.join(this.projectRoot, file.path), 'utf8').split('\n');
|
|
171
|
+
const excerpts = [];
|
|
172
|
+
for (let index = 0; index < contentLines.length && excerpts.length < 3; index++) {
|
|
173
|
+
const lower = contentLines[index].toLowerCase();
|
|
174
|
+
if ([...query].some((term) => lower.includes(term))) {
|
|
175
|
+
excerpts.push(`${index + 1}:${contentLines[index].trim().slice(0, 160)}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (excerpts.length > 0)
|
|
179
|
+
matches = ` matches: ${excerpts.join(' | ')}`;
|
|
180
|
+
}
|
|
181
|
+
catch { }
|
|
182
|
+
return `- ${file.path} [score ${score}]${symbols ? ` symbols: ${symbols}` : ''}${matches}`;
|
|
183
|
+
});
|
|
184
|
+
return `TASK-ORIENTED LOCAL INDEX\n${lines.join('\n')}\nStart with these candidates; do not explore unrelated folders.`;
|
|
185
|
+
}
|
|
186
|
+
}
|