@opensassi/opencode 0.1.3 → 0.1.5
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/AGENTS.md +3 -2
- package/dashboard/dashboard.e2e.test.ts +247 -0
- package/dashboard/dist/index.d.ts +9 -0
- package/dashboard/dist/index.js +36 -0
- package/dashboard/dist/routes/api.d.ts +2 -0
- package/dashboard/dist/routes/api.js +215 -0
- package/dashboard/dist/services/cache.d.ts +13 -0
- package/dashboard/dist/services/cache.js +29 -0
- package/dashboard/dist/services/experiments.d.ts +11 -0
- package/dashboard/dist/services/experiments.js +108 -0
- package/dashboard/dist/services/git.d.ts +12 -0
- package/dashboard/dist/services/git.js +149 -0
- package/dashboard/dist/services/sessions.d.ts +25 -0
- package/dashboard/dist/services/sessions.js +208 -0
- package/dashboard/dist/services/specs.d.ts +9 -0
- package/dashboard/dist/services/specs.js +102 -0
- package/dashboard/dist/types.d.ts +173 -0
- package/dashboard/dist/types.js +1 -0
- package/dashboard/opencode.e2e.test.ts +100 -0
- package/dashboard/playwright.config.ts +11 -0
- package/dashboard/public/app.js +961 -0
- package/dashboard/public/index.html +29 -0
- package/dashboard/public/style.css +231 -0
- package/dashboard/src/index.ts +53 -0
- package/dashboard/src/routes/api.ts +235 -0
- package/dashboard/src/services/cache.ts +38 -0
- package/dashboard/src/services/experiments.ts +117 -0
- package/dashboard/src/services/git.ts +139 -0
- package/dashboard/src/services/sessions.ts +216 -0
- package/dashboard/src/services/specs.ts +95 -0
- package/dashboard/src/types.ts +168 -0
- package/dashboard/technical-specification.md +414 -0
- package/dashboard/test-api.sh +127 -0
- package/dashboard/tsconfig.json +16 -0
- package/lib/util/paths.js +9 -1
- package/package.json +10 -1
- package/scripts/dashboard.js +17 -0
- package/scripts/generate-daily-summaries.js +190 -0
- package/skills/opensassi/SKILL.md +8 -5
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, relative } from 'node:path';
|
|
3
|
+
export class ExperimentsService {
|
|
4
|
+
experimentsDir;
|
|
5
|
+
constructor(experimentsDir) {
|
|
6
|
+
this.experimentsDir = resolve(experimentsDir);
|
|
7
|
+
}
|
|
8
|
+
listExperiments() {
|
|
9
|
+
const indexPath = join(this.experimentsDir, 'README.md');
|
|
10
|
+
if (!existsSync(indexPath))
|
|
11
|
+
return [];
|
|
12
|
+
const content = readFileSync(indexPath, 'utf-8');
|
|
13
|
+
return this.parseTable(content);
|
|
14
|
+
}
|
|
15
|
+
getExperiment(directory) {
|
|
16
|
+
const entries = this.listExperiments();
|
|
17
|
+
const entry = entries.find(e => e.directory === directory || e.directory.replace(/\/$/, '') === directory.replace(/\/$/, ''));
|
|
18
|
+
if (!entry)
|
|
19
|
+
return null;
|
|
20
|
+
const dirPath = join(this.experimentsDir, entry.directory.replace(/\/$/, ''));
|
|
21
|
+
if (!existsSync(dirPath))
|
|
22
|
+
return null;
|
|
23
|
+
let readme = null;
|
|
24
|
+
const readmePath = join(dirPath, 'README.md');
|
|
25
|
+
if (existsSync(readmePath)) {
|
|
26
|
+
readme = readFileSync(readmePath, 'utf-8');
|
|
27
|
+
}
|
|
28
|
+
const subdirs = [];
|
|
29
|
+
const allFiles = [];
|
|
30
|
+
const expPrefix = entry.directory.replace(/\/$/, '');
|
|
31
|
+
const items = readdirSync(dirPath);
|
|
32
|
+
for (const item of items) {
|
|
33
|
+
const itemPath = join(dirPath, item);
|
|
34
|
+
const stat = statSync(itemPath);
|
|
35
|
+
if (stat.isDirectory()) {
|
|
36
|
+
const files = this.listFilesRecursive(itemPath, this.experimentsDir);
|
|
37
|
+
subdirs.push({ name: item, files });
|
|
38
|
+
allFiles.push(...files);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const file = {
|
|
42
|
+
path: relative(this.experimentsDir, itemPath),
|
|
43
|
+
name: item,
|
|
44
|
+
size: stat.size,
|
|
45
|
+
};
|
|
46
|
+
if (item !== 'README.md') {
|
|
47
|
+
allFiles.push(file);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { entry, readme, subdirs, allFiles };
|
|
52
|
+
}
|
|
53
|
+
readFile(filePath) {
|
|
54
|
+
const fullPath = join(this.experimentsDir, filePath);
|
|
55
|
+
if (!existsSync(fullPath))
|
|
56
|
+
return null;
|
|
57
|
+
return readFileSync(fullPath, 'utf-8');
|
|
58
|
+
}
|
|
59
|
+
readFileRaw(filePath) {
|
|
60
|
+
const fullPath = join(this.experimentsDir, filePath);
|
|
61
|
+
if (!existsSync(fullPath))
|
|
62
|
+
return null;
|
|
63
|
+
return readFileSync(fullPath);
|
|
64
|
+
}
|
|
65
|
+
listFilesRecursive(dirPath, rootDir) {
|
|
66
|
+
const files = [];
|
|
67
|
+
const items = readdirSync(dirPath);
|
|
68
|
+
for (const item of items) {
|
|
69
|
+
const itemPath = join(dirPath, item);
|
|
70
|
+
const stat = statSync(itemPath);
|
|
71
|
+
if (stat.isDirectory()) {
|
|
72
|
+
files.push(...this.listFilesRecursive(itemPath, rootDir));
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
files.push({
|
|
76
|
+
path: relative(rootDir, itemPath),
|
|
77
|
+
name: item,
|
|
78
|
+
size: stat.size,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return files;
|
|
83
|
+
}
|
|
84
|
+
parseTable(content) {
|
|
85
|
+
const entries = [];
|
|
86
|
+
const lines = content.split('\n');
|
|
87
|
+
let inTable = false;
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
const trimmed = line.trim();
|
|
90
|
+
if (trimmed.startsWith('|') && trimmed.includes('---')) {
|
|
91
|
+
inTable = true;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (!trimmed.startsWith('|') || !inTable)
|
|
95
|
+
continue;
|
|
96
|
+
const cells = trimmed.split('|').slice(1, -1).map(c => c.trim());
|
|
97
|
+
if (cells.length < 5)
|
|
98
|
+
continue;
|
|
99
|
+
const date = cells[0].trim();
|
|
100
|
+
const dirCell = cells[1].replace(/^`|`$/g, '').replace(/\/$/, '').trim();
|
|
101
|
+
const desc = cells[2].replace(/^`|`$/g, '').trim();
|
|
102
|
+
const outcome = cells[3].replace(/\*\*/g, '').trim();
|
|
103
|
+
const agent = cells[4].replace(/^`|`$/g, '').trim();
|
|
104
|
+
entries.push({ date, directory: dirCell, description: desc, outcome, agent });
|
|
105
|
+
}
|
|
106
|
+
return entries;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { GitLogEntry, GitStats } from '../types.js';
|
|
2
|
+
export declare class GitService {
|
|
3
|
+
private repoDir;
|
|
4
|
+
private defaultSince;
|
|
5
|
+
constructor(repoDir: string, defaultSince?: string | null);
|
|
6
|
+
detectForkPoint(): string | null;
|
|
7
|
+
getRange(rangeBase: string | null): string | undefined;
|
|
8
|
+
getLog(since?: string, until?: string, forkRange?: string | null): GitLogEntry[];
|
|
9
|
+
private parseLogOutput;
|
|
10
|
+
getStats(forkRange?: string | null): GitStats;
|
|
11
|
+
getCommitDiff(hash: string): string;
|
|
12
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
export class GitService {
|
|
3
|
+
repoDir;
|
|
4
|
+
defaultSince;
|
|
5
|
+
constructor(repoDir, defaultSince) {
|
|
6
|
+
this.repoDir = repoDir;
|
|
7
|
+
this.defaultSince = defaultSince ?? null;
|
|
8
|
+
}
|
|
9
|
+
detectForkPoint() {
|
|
10
|
+
const makeOpts = (mb) => ({
|
|
11
|
+
cwd: this.repoDir, encoding: 'utf-8', maxBuffer: mb,
|
|
12
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
13
|
+
});
|
|
14
|
+
try {
|
|
15
|
+
const base = execFileSync('git', ['merge-base', 'HEAD', 'upstream/main'], makeOpts(1024 * 1024)).trim();
|
|
16
|
+
if (base)
|
|
17
|
+
return base;
|
|
18
|
+
}
|
|
19
|
+
catch { }
|
|
20
|
+
try {
|
|
21
|
+
const allFork = execFileSync('git', ['rev-list', '--reverse', '--author=opensassi', 'HEAD'], makeOpts(10 * 1024 * 1024)).trim().split('\n').filter(Boolean);
|
|
22
|
+
if (allFork.length > 0) {
|
|
23
|
+
const parent = execFileSync('git', ['rev-parse', allFork[0] + '^'], makeOpts(1024 * 1024)).trim();
|
|
24
|
+
if (parent)
|
|
25
|
+
return parent;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch { }
|
|
29
|
+
try {
|
|
30
|
+
const allFork = execFileSync('git', ['rev-list', '--reverse', '--author=Ersun', 'HEAD'], makeOpts(10 * 1024 * 1024)).trim().split('\n').filter(Boolean);
|
|
31
|
+
if (allFork.length > 0) {
|
|
32
|
+
const parent = execFileSync('git', ['rev-parse', allFork[0] + '^'], makeOpts(1024 * 1024)).trim();
|
|
33
|
+
if (parent)
|
|
34
|
+
return parent;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
getRange(rangeBase) {
|
|
41
|
+
if (!rangeBase)
|
|
42
|
+
return undefined;
|
|
43
|
+
return rangeBase + '..HEAD';
|
|
44
|
+
}
|
|
45
|
+
getLog(since, until, forkRange) {
|
|
46
|
+
const args = ['log', '--oneline', '--stat', '--no-merges', '--format=COMMIT%x1e%H%x1f%an%x1f%ai%x1f%s'];
|
|
47
|
+
if (forkRange) {
|
|
48
|
+
args.push(forkRange);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const s = since ?? this.defaultSince;
|
|
52
|
+
if (s)
|
|
53
|
+
args.push('--after', s);
|
|
54
|
+
if (until)
|
|
55
|
+
args.push('--before', until);
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const output = execFileSync('git', args, { cwd: this.repoDir, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
59
|
+
return this.parseLogOutput(output);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
parseLogOutput(output) {
|
|
66
|
+
const entries = [];
|
|
67
|
+
const blocks = output.split('\x1e').filter(b => b.trim());
|
|
68
|
+
for (let bi = 0; bi < blocks.length; bi++) {
|
|
69
|
+
const block = blocks[bi];
|
|
70
|
+
const lines = block.trim().split('\n');
|
|
71
|
+
if (lines.length === 0)
|
|
72
|
+
continue;
|
|
73
|
+
const header = lines[0];
|
|
74
|
+
const parts = header.split('\x1f');
|
|
75
|
+
// First block is just "COMMIT" (before first \x1e), skip it
|
|
76
|
+
if (bi === 0 && parts.length === 1 && parts[0] === 'COMMIT')
|
|
77
|
+
continue;
|
|
78
|
+
// Subsequent blocks: hash\x1fauthor\x1fdate\x1fsubject
|
|
79
|
+
if (parts.length < 3)
|
|
80
|
+
continue;
|
|
81
|
+
const commit = parts[0];
|
|
82
|
+
const author = parts[1];
|
|
83
|
+
const date = parts[2];
|
|
84
|
+
const message = parts.slice(3).join('\x1f');
|
|
85
|
+
let filesChanged = 0;
|
|
86
|
+
let insertions = 0;
|
|
87
|
+
let deletions = 0;
|
|
88
|
+
for (let i = 1; i < lines.length; i++) {
|
|
89
|
+
const statMatch = lines[i].match(/(\d+) file[s]? changed/);
|
|
90
|
+
if (statMatch)
|
|
91
|
+
filesChanged = parseInt(statMatch[1], 10);
|
|
92
|
+
const insMatch = lines[i].match(/(\d+) insertion[s]?/);
|
|
93
|
+
if (insMatch)
|
|
94
|
+
insertions = parseInt(insMatch[1], 10);
|
|
95
|
+
const delMatch = lines[i].match(/(\d+) deletion[s]?/);
|
|
96
|
+
if (delMatch)
|
|
97
|
+
deletions = parseInt(delMatch[1], 10);
|
|
98
|
+
}
|
|
99
|
+
entries.push({
|
|
100
|
+
commit: commit ?? '',
|
|
101
|
+
author: author ?? '',
|
|
102
|
+
date: date ?? '',
|
|
103
|
+
message: message ?? '',
|
|
104
|
+
files_changed: filesChanged,
|
|
105
|
+
insertions,
|
|
106
|
+
deletions,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return entries;
|
|
110
|
+
}
|
|
111
|
+
getStats(forkRange) {
|
|
112
|
+
const allLog = this.getLog(undefined, undefined, forkRange);
|
|
113
|
+
const perDate = {};
|
|
114
|
+
let totalFilesChanged = 0;
|
|
115
|
+
let totalInsertions = 0;
|
|
116
|
+
let totalDeletions = 0;
|
|
117
|
+
for (const entry of allLog) {
|
|
118
|
+
const day = entry.date.slice(0, 10);
|
|
119
|
+
if (!perDate[day])
|
|
120
|
+
perDate[day] = { commits: 0, insertions: 0, deletions: 0 };
|
|
121
|
+
perDate[day].commits++;
|
|
122
|
+
perDate[day].insertions += entry.insertions;
|
|
123
|
+
perDate[day].deletions += entry.deletions;
|
|
124
|
+
totalFilesChanged += entry.files_changed;
|
|
125
|
+
totalInsertions += entry.insertions;
|
|
126
|
+
totalDeletions += entry.deletions;
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
total_commits: allLog.length,
|
|
130
|
+
total_files_changed: totalFilesChanged,
|
|
131
|
+
total_insertions: totalInsertions,
|
|
132
|
+
total_deletions: totalDeletions,
|
|
133
|
+
per_date: perDate,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
getCommitDiff(hash) {
|
|
137
|
+
try {
|
|
138
|
+
return execFileSync('git', ['show', hash, '--no-color'], {
|
|
139
|
+
cwd: this.repoDir,
|
|
140
|
+
encoding: 'utf-8',
|
|
141
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
142
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return '';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { NormalizedDay, SessionDetail, SessionEntry, SearchResult } from '../types.js';
|
|
2
|
+
export interface SessionsConfig {
|
|
3
|
+
sessionsDir: string;
|
|
4
|
+
}
|
|
5
|
+
export declare class SessionsService {
|
|
6
|
+
private dailyCache;
|
|
7
|
+
private detailCache;
|
|
8
|
+
private daysListCache;
|
|
9
|
+
private sessionsDir;
|
|
10
|
+
constructor(sessionsDir: string);
|
|
11
|
+
get dailyDir(): string;
|
|
12
|
+
listDays(): string[];
|
|
13
|
+
listSessionsDirFiles(): string[];
|
|
14
|
+
getDay(date: string): NormalizedDay;
|
|
15
|
+
getAllSessionsFlat(): Array<{
|
|
16
|
+
date: string;
|
|
17
|
+
entry: SessionEntry;
|
|
18
|
+
}>;
|
|
19
|
+
getSessionDetail(sessionId: string): SessionDetail | null;
|
|
20
|
+
getSessionSummary(sessionId: string): string | null;
|
|
21
|
+
private resolveBz2File;
|
|
22
|
+
private resolveMdFile;
|
|
23
|
+
search(query: string): SearchResult[];
|
|
24
|
+
refresh(): void;
|
|
25
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { TTLCache } from './cache.js';
|
|
5
|
+
function normalizeDay(raw, date) {
|
|
6
|
+
const a = raw;
|
|
7
|
+
if (a && typeof a === 'object' && 'dashboard' in a && a.dashboard) {
|
|
8
|
+
const ds = a.dashboard.daily_summary;
|
|
9
|
+
return {
|
|
10
|
+
date: ds.date,
|
|
11
|
+
metadata: a.dashboard.metadata,
|
|
12
|
+
total_prompter_time_hours: ds.total_prompter_time_hours,
|
|
13
|
+
total_sme_time_hours: ds.total_sme_time_hours,
|
|
14
|
+
ai_multiplier: ds.ai_multiplier,
|
|
15
|
+
total_sessions: ds.total_sessions,
|
|
16
|
+
top_subject_areas: ds.top_subject_areas,
|
|
17
|
+
session_breakdown: a.dashboard.session_breakdown,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const b = raw;
|
|
21
|
+
if (b && typeof b === 'object' && 'date' in b && b.date) {
|
|
22
|
+
return {
|
|
23
|
+
date: b.date,
|
|
24
|
+
total_prompter_time_hours: b.total_prompter_time_hours,
|
|
25
|
+
total_sme_time_hours: b.total_sme_time_hours,
|
|
26
|
+
ai_multiplier: b.ai_multiplier,
|
|
27
|
+
total_sessions: b.total_sessions,
|
|
28
|
+
top_subject_areas: b.top_subject_areas,
|
|
29
|
+
session_breakdown: b.session_breakdown,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
date,
|
|
34
|
+
total_prompter_time_hours: 0,
|
|
35
|
+
total_sme_time_hours: 0,
|
|
36
|
+
ai_multiplier: 0,
|
|
37
|
+
total_sessions: 0,
|
|
38
|
+
top_subject_areas: [],
|
|
39
|
+
session_breakdown: [],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export class SessionsService {
|
|
43
|
+
dailyCache = new TTLCache(60_000);
|
|
44
|
+
detailCache = new TTLCache(300_000);
|
|
45
|
+
daysListCache = new TTLCache(30_000);
|
|
46
|
+
sessionsDir;
|
|
47
|
+
constructor(sessionsDir) {
|
|
48
|
+
this.sessionsDir = resolve(sessionsDir);
|
|
49
|
+
}
|
|
50
|
+
get dailyDir() {
|
|
51
|
+
return join(this.sessionsDir, 'daily');
|
|
52
|
+
}
|
|
53
|
+
listDays() {
|
|
54
|
+
const cached = this.daysListCache.get('list');
|
|
55
|
+
if (cached)
|
|
56
|
+
return cached;
|
|
57
|
+
const dir = this.dailyDir;
|
|
58
|
+
if (!existsSync(dir))
|
|
59
|
+
return [];
|
|
60
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.json')).map(f => f.replace(/\.json$/, '')).sort();
|
|
61
|
+
this.daysListCache.set('list', files);
|
|
62
|
+
return files;
|
|
63
|
+
}
|
|
64
|
+
listSessionsDirFiles() {
|
|
65
|
+
if (!existsSync(this.sessionsDir))
|
|
66
|
+
return [];
|
|
67
|
+
return readdirSync(this.sessionsDir).filter(f => f.endsWith('.json.bz2'));
|
|
68
|
+
}
|
|
69
|
+
getDay(date) {
|
|
70
|
+
const cached = this.dailyCache.get(date);
|
|
71
|
+
if (cached)
|
|
72
|
+
return cached;
|
|
73
|
+
const path = join(this.dailyDir, `${date}.json`);
|
|
74
|
+
if (!existsSync(path)) {
|
|
75
|
+
const day = {
|
|
76
|
+
date,
|
|
77
|
+
total_prompter_time_hours: 0,
|
|
78
|
+
total_sme_time_hours: 0,
|
|
79
|
+
ai_multiplier: 0,
|
|
80
|
+
total_sessions: 0,
|
|
81
|
+
top_subject_areas: [],
|
|
82
|
+
session_breakdown: [],
|
|
83
|
+
};
|
|
84
|
+
this.dailyCache.set(date, day);
|
|
85
|
+
return day;
|
|
86
|
+
}
|
|
87
|
+
const raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
88
|
+
const day = normalizeDay(raw, date);
|
|
89
|
+
this.dailyCache.set(date, day);
|
|
90
|
+
return day;
|
|
91
|
+
}
|
|
92
|
+
getAllSessionsFlat() {
|
|
93
|
+
const days = this.listDays();
|
|
94
|
+
const result = [];
|
|
95
|
+
for (const day of days) {
|
|
96
|
+
const normalized = this.getDay(day);
|
|
97
|
+
for (const entry of normalized.session_breakdown) {
|
|
98
|
+
result.push({ date: day, entry });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
getSessionDetail(sessionId) {
|
|
104
|
+
const cached = this.detailCache.get(sessionId);
|
|
105
|
+
if (cached)
|
|
106
|
+
return cached;
|
|
107
|
+
const bz2File = this.resolveBz2File(sessionId);
|
|
108
|
+
if (!bz2File)
|
|
109
|
+
return null;
|
|
110
|
+
try {
|
|
111
|
+
const json = execFileSync('bzcat', [bz2File], { encoding: 'utf-8', maxBuffer: 100 * 1024 * 1024 });
|
|
112
|
+
const detail = JSON.parse(json);
|
|
113
|
+
this.detailCache.set(sessionId, detail);
|
|
114
|
+
return detail;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
getSessionSummary(sessionId) {
|
|
121
|
+
const mdFile = this.resolveMdFile(sessionId);
|
|
122
|
+
if (!mdFile)
|
|
123
|
+
return null;
|
|
124
|
+
return readFileSync(mdFile, 'utf-8');
|
|
125
|
+
}
|
|
126
|
+
resolveBz2File(sessionId) {
|
|
127
|
+
const exact = join(this.sessionsDir, `${sessionId}.json.bz2`);
|
|
128
|
+
if (existsSync(exact))
|
|
129
|
+
return exact;
|
|
130
|
+
const files = this.listSessionsDirFiles();
|
|
131
|
+
const match = files.find(f => f.startsWith(sessionId) && f.endsWith('.json.bz2'));
|
|
132
|
+
if (match)
|
|
133
|
+
return join(this.sessionsDir, match);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
resolveMdFile(sessionId) {
|
|
137
|
+
const exact = join(this.sessionsDir, `${sessionId}.md`);
|
|
138
|
+
if (existsSync(exact))
|
|
139
|
+
return exact;
|
|
140
|
+
if (!existsSync(this.sessionsDir))
|
|
141
|
+
return null;
|
|
142
|
+
const files = readdirSync(this.sessionsDir).filter(f => f.endsWith('.md'));
|
|
143
|
+
const match = files.find(f => f.startsWith(sessionId));
|
|
144
|
+
if (match)
|
|
145
|
+
return join(this.sessionsDir, match);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
search(query) {
|
|
149
|
+
const q = query.toLowerCase();
|
|
150
|
+
const results = [];
|
|
151
|
+
const days = this.listDays();
|
|
152
|
+
for (const day of days) {
|
|
153
|
+
const normalized = this.getDay(day);
|
|
154
|
+
for (const entry of normalized.session_breakdown) {
|
|
155
|
+
if (entry.top_component_summary.toLowerCase().includes(q)) {
|
|
156
|
+
results.push({
|
|
157
|
+
session_id: entry.session_id,
|
|
158
|
+
date: day,
|
|
159
|
+
summary: entry.top_component_summary,
|
|
160
|
+
tags: entry.tags,
|
|
161
|
+
match_type: 'summary',
|
|
162
|
+
match_snippet: entry.top_component_summary,
|
|
163
|
+
});
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const matchedTag = entry.tags.find(t => t.toLowerCase().includes(q));
|
|
167
|
+
if (matchedTag) {
|
|
168
|
+
results.push({
|
|
169
|
+
session_id: entry.session_id,
|
|
170
|
+
date: day,
|
|
171
|
+
summary: entry.top_component_summary,
|
|
172
|
+
tags: entry.tags,
|
|
173
|
+
match_type: 'tag',
|
|
174
|
+
match_snippet: matchedTag,
|
|
175
|
+
});
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const detail = this.getSessionDetail(entry.session_id);
|
|
179
|
+
if (detail) {
|
|
180
|
+
for (const msg of detail.messages) {
|
|
181
|
+
for (const part of msg.parts) {
|
|
182
|
+
if (part.text && part.text.toLowerCase().includes(q)) {
|
|
183
|
+
const snippet = part.text.slice(Math.max(0, part.text.toLowerCase().indexOf(q) - 40), part.text.toLowerCase().indexOf(q) + 80);
|
|
184
|
+
results.push({
|
|
185
|
+
session_id: entry.session_id,
|
|
186
|
+
date: day,
|
|
187
|
+
summary: entry.top_component_summary,
|
|
188
|
+
tags: entry.tags,
|
|
189
|
+
match_type: 'transcript',
|
|
190
|
+
match_snippet: snippet,
|
|
191
|
+
});
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (results.length > 0 && results[results.length - 1].session_id === entry.session_id && results[results.length - 1].match_type === 'transcript')
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return results;
|
|
202
|
+
}
|
|
203
|
+
refresh() {
|
|
204
|
+
this.dailyCache.invalidateAll();
|
|
205
|
+
this.daysListCache.invalidateAll();
|
|
206
|
+
this.detailCache.invalidateAll();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, relative } from 'node:path';
|
|
3
|
+
const SKIP_DIRS = new Set(['node_modules', 'thirdparty', 'external', '.git', 'build', 'build_debug', 'build_ml', 'build_ml_release', 'build_baseline', 'build_clean', 'build_clean_p3', 'build_clean_test', 'build_debug', 'build_final_clean', 'build_hw', 'build_hw_dbg', 'build_off', 'build_phase2', 'build_pipeline_baseline', 'build_release', 'build_sched', 'build_sched_full', 'build_sched_trace', 'lib', 'bin', 'install', 'pkgconfig', '.artifacts', '.github', '.playwright-mcp', '.profiler', 'ml-data', 'ml-models', 'ml-models-v2', 'logs', 'sessions', 'coverage', 'deps', 'cfg', 'docs', 'microbench', 'perf', 'test', 'thirdparty']);
|
|
4
|
+
export class TechSpecService {
|
|
5
|
+
rootDir;
|
|
6
|
+
constructor(rootDir) {
|
|
7
|
+
this.rootDir = resolve(rootDir);
|
|
8
|
+
}
|
|
9
|
+
getTree() {
|
|
10
|
+
return {
|
|
11
|
+
name: 'Technical Specifications',
|
|
12
|
+
path: '',
|
|
13
|
+
isDir: true,
|
|
14
|
+
children: this.scanDir(this.rootDir, 6),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
readSpec(specPath) {
|
|
18
|
+
const fullPath = join(this.rootDir, specPath);
|
|
19
|
+
if (!existsSync(fullPath))
|
|
20
|
+
return null;
|
|
21
|
+
return readFileSync(fullPath, 'utf-8');
|
|
22
|
+
}
|
|
23
|
+
scanDir(dir, maxDepth) {
|
|
24
|
+
if (maxDepth <= 0)
|
|
25
|
+
return [];
|
|
26
|
+
const entries = [];
|
|
27
|
+
try {
|
|
28
|
+
const items = readdirSync(dir);
|
|
29
|
+
const dirs = [];
|
|
30
|
+
const specFiles = [];
|
|
31
|
+
for (const item of items) {
|
|
32
|
+
if (SKIP_DIRS.has(item))
|
|
33
|
+
continue;
|
|
34
|
+
const fullPath = join(dir, item);
|
|
35
|
+
let stat;
|
|
36
|
+
try {
|
|
37
|
+
stat = statSync(fullPath);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (stat.isDirectory()) {
|
|
43
|
+
if (this.hasSpecs(fullPath)) {
|
|
44
|
+
dirs.push(fullPath);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else if (item.endsWith('.spec.md') || item === 'technical-specification.md') {
|
|
48
|
+
specFiles.push({
|
|
49
|
+
name: item,
|
|
50
|
+
path: relative(this.rootDir, fullPath),
|
|
51
|
+
isDir: false,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const d of dirs.sort()) {
|
|
56
|
+
const relPath = relative(this.rootDir, d);
|
|
57
|
+
entries.push({
|
|
58
|
+
name: relPath,
|
|
59
|
+
path: relPath,
|
|
60
|
+
isDir: true,
|
|
61
|
+
children: this.scanDir(d, maxDepth - 1),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
entries.push(...specFiles);
|
|
65
|
+
entries.sort((a, b) => {
|
|
66
|
+
// Root spec first, then aggregate specs, then dirs, then other files
|
|
67
|
+
if (a.name === 'technical-specification.md')
|
|
68
|
+
return -1;
|
|
69
|
+
if (b.name === 'technical-specification.md')
|
|
70
|
+
return 1;
|
|
71
|
+
if (a.isDir && !b.isDir)
|
|
72
|
+
return 1;
|
|
73
|
+
if (!a.isDir && b.isDir)
|
|
74
|
+
return -1;
|
|
75
|
+
return a.name.localeCompare(b.name);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch { }
|
|
79
|
+
return entries;
|
|
80
|
+
}
|
|
81
|
+
hasSpecs(dir) {
|
|
82
|
+
try {
|
|
83
|
+
const items = readdirSync(dir);
|
|
84
|
+
for (const item of items) {
|
|
85
|
+
if (SKIP_DIRS.has(item))
|
|
86
|
+
continue;
|
|
87
|
+
if (item.endsWith('.spec.md') || item === 'technical-specification.md')
|
|
88
|
+
return true;
|
|
89
|
+
const fullPath = join(dir, item);
|
|
90
|
+
try {
|
|
91
|
+
if (statSync(fullPath).isDirectory() && !SKIP_DIRS.has(item)) {
|
|
92
|
+
if (this.hasSpecs(fullPath))
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch { }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch { }
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|