repoburg 1.3.227 → 1.3.229

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.
@@ -18,6 +18,7 @@ const agentPane_1 = require("./agentPane");
18
18
  const routes_1 = require("./remote/routes");
19
19
  const checkRemoteAuth_1 = require("./middleware/checkRemoteAuth");
20
20
  const staticServing_1 = require("./staticServing");
21
+ const router_1 = require("./recorder/router");
21
22
  const PORT = 9998;
22
23
  async function main() {
23
24
  await serviceManager_1.serviceManager.connect();
@@ -53,6 +54,12 @@ async function main() {
53
54
  app.use('/services', services_1.default);
54
55
  app.use('/auth', (0, auth_1.default)(wss));
55
56
  app.use('/registry', registry_1.default);
57
+ // Session event recorder: POST /recorder/events (collector) + static bundle
58
+ // for the pane's one-line script include. Must precede the pane SPA router.
59
+ // distPath is resolved from THIS file's dir (daemon/src in dev, daemon/dist
60
+ // in prod): ../../recorder/dist — router.ts sits one level deeper, so its
61
+ // own __dirname would resolve one directory short.
62
+ app.use('/recorder', (0, router_1.createRecorderRouter)({ distPath: (0, router_1.resolveRecorderDist)(__dirname) }));
56
63
  // MCP Gateway — single MCP entry point managing all instances (streamableHttp).
57
64
  app.post('/mcp', (req, res) => mcp_gateway_1.mcpGateway.handleExpressRequest(req, res));
58
65
  app.get('/mcp', (req, res) => mcp_gateway_1.mcpGateway.handleExpressRequest(req, res));
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.EVENT_TYPES = void 0;
7
+ exports.validateSid = validateSid;
8
+ exports.validateEvents = validateEvents;
9
+ exports.appendSessionEvents = appendSessionEvents;
10
+ const fs_extra_1 = __importDefault(require("fs-extra"));
11
+ const path_1 = __importDefault(require("path"));
12
+ /**
13
+ * Collector validation + append logic for the recorder event stream.
14
+ * One JSONL file per session (sid); the file name is derived from the
15
+ * sid, so it must be strictly filename-safe. Lines are appended in
16
+ * arrival order — the recorder guarantees time-ordered events.
17
+ */
18
+ const SID_RE = /^[a-zA-Z0-9_-]{4,64}$/;
19
+ exports.EVENT_TYPES = new Set([
20
+ 'session_start',
21
+ 'session_end',
22
+ 'route_change',
23
+ 'click',
24
+ 'scroll',
25
+ 'dom_update',
26
+ 'request_start',
27
+ 'request_end',
28
+ 'request_fail',
29
+ 'error',
30
+ 'long_task',
31
+ 'lcp',
32
+ 'inp',
33
+ 'cls',
34
+ ]);
35
+ /** Validate and return the session id, or throw a descriptive error. */
36
+ function validateSid(sid) {
37
+ if (typeof sid !== 'string' || !SID_RE.test(sid)) {
38
+ throw new Error('invalid session id');
39
+ }
40
+ return sid;
41
+ }
42
+ /** Validate a batch body: an array of flat event objects with known types. */
43
+ function validateEvents(events) {
44
+ if (!Array.isArray(events))
45
+ throw new Error('events must be an array');
46
+ for (const ev of events) {
47
+ if (typeof ev !== 'object' || ev === null)
48
+ throw new Error('event must be an object');
49
+ const type = ev.type;
50
+ if (typeof type !== 'string' || !exports.EVENT_TYPES.has(type)) {
51
+ throw new Error(`unknown event type: ${String(type)}`);
52
+ }
53
+ }
54
+ return events;
55
+ }
56
+ /** Append a validated batch to <sessionsDir>/<sid>.jsonl. Returns the file path. */
57
+ function appendSessionEvents(sessionsDir, sid, events) {
58
+ const file = path_1.default.join(sessionsDir, `${sid}.jsonl`);
59
+ const lines = events.map((ev) => JSON.stringify(ev)).join('\n') + '\n';
60
+ fs_extra_1.default.appendFileSync(file, lines, 'utf8');
61
+ return file;
62
+ }
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_extra_1 = __importDefault(require("fs-extra"));
7
+ const os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const collector_1 = require("./collector");
10
+ const retention_1 = require("./retention");
11
+ describe('collector', () => {
12
+ let dir;
13
+ beforeEach(() => {
14
+ dir = fs_extra_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'recorder-test-'));
15
+ });
16
+ afterEach(() => {
17
+ fs_extra_1.default.removeSync(dir);
18
+ });
19
+ it('accepts a filename-safe session id', () => {
20
+ expect((0, collector_1.validateSid)('a91f0c3d44e2')).toBe('a91f0c3d44e2');
21
+ });
22
+ it('rejects path-traversal session ids', () => {
23
+ expect(() => (0, collector_1.validateSid)('../etc')).toThrow();
24
+ expect(() => (0, collector_1.validateSid)('')).toThrow();
25
+ expect(() => (0, collector_1.validateSid)(42)).toThrow();
26
+ expect(() => (0, collector_1.validateSid)('abc')).toThrow(); // too short
27
+ });
28
+ it('accepts valid event batches and rejects malformed ones', () => {
29
+ expect((0, collector_1.validateEvents)([{ type: 'click', target: '#x' }])).toHaveLength(1);
30
+ expect(() => (0, collector_1.validateEvents)('nope')).toThrow();
31
+ expect(() => (0, collector_1.validateEvents)([{ type: 'bogus' }])).toThrow();
32
+ expect(() => (0, collector_1.validateEvents)([null])).toThrow();
33
+ });
34
+ it('appends events as JSONL lines in order', () => {
35
+ const events = [
36
+ { ts: 1, t: 0, sid: 'sess1234', type: 'session_start', route: '/' },
37
+ { ts: 2, t: 1, sid: 'sess1234', type: 'click', target: '#send' },
38
+ ];
39
+ const file = (0, collector_1.appendSessionEvents)(dir, 'sess1234', events);
40
+ expect(file).toBe(path_1.default.join(dir, 'sess1234.jsonl'));
41
+ const lines = fs_extra_1.default.readFileSync(file, 'utf8').trim().split('\n');
42
+ expect(lines).toHaveLength(2);
43
+ expect(JSON.parse(lines[0]).type).toBe('session_start');
44
+ expect(JSON.parse(lines[1]).target).toBe('#send');
45
+ // A second batch appends to the same file.
46
+ (0, collector_1.appendSessionEvents)(dir, 'sess1234', [{ ts: 3, t: 2, sid: 'sess1234', type: 'error', message: 'x' }]);
47
+ expect(fs_extra_1.default.readFileSync(file, 'utf8').trim().split('\n')).toHaveLength(3);
48
+ });
49
+ });
50
+ describe('retention (ring buffer)', () => {
51
+ let dir;
52
+ beforeEach(() => {
53
+ dir = fs_extra_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'recorder-ret-'));
54
+ });
55
+ afterEach(() => {
56
+ fs_extra_1.default.removeSync(dir);
57
+ });
58
+ const writeSession = (name, sizeBytes, mtimeMsAgo) => {
59
+ const file = path_1.default.join(dir, `${name}.jsonl`);
60
+ fs_extra_1.default.writeFileSync(file, 'x'.repeat(sizeBytes));
61
+ const past = new Date(Date.now() - mtimeMsAgo);
62
+ fs_extra_1.default.utimesSync(file, past, past);
63
+ };
64
+ it('deletes nothing when under the quota', () => {
65
+ writeSession('aaa11111', 100, 10000);
66
+ const deleted = (0, retention_1.enforceQuota)(dir, 1000);
67
+ expect(deleted).toHaveLength(0);
68
+ });
69
+ it('deletes oldest sessions first until under the quota', () => {
70
+ writeSession('old111111', 500, 30000);
71
+ writeSession('mid111111', 500, 20000);
72
+ writeSession('new111111', 500, 10000);
73
+ // Quota 600: 1500 -> delete old (1000) -> delete mid (500 <= 600) -> stop.
74
+ const deleted = (0, retention_1.enforceQuota)(dir, 600);
75
+ expect(deleted).toEqual([path_1.default.join(dir, 'old111111.jsonl'), path_1.default.join(dir, 'mid111111.jsonl')]);
76
+ expect(fs_extra_1.default.existsSync(path_1.default.join(dir, 'new111111.jsonl'))).toBe(true);
77
+ });
78
+ it('ignores non-jsonl files and a missing directory', () => {
79
+ fs_extra_1.default.writeFileSync(path_1.default.join(dir, 'notes.txt'), 'keep me');
80
+ writeSession('sess2222', 100, 1000);
81
+ const deleted = (0, retention_1.enforceQuota)(dir, 10);
82
+ expect(deleted).toHaveLength(1);
83
+ expect(fs_extra_1.default.existsSync(path_1.default.join(dir, 'notes.txt'))).toBe(true);
84
+ expect((0, retention_1.enforceQuota)(path_1.default.join(dir, 'missing'), 10)).toEqual([]);
85
+ });
86
+ });
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.enforceQuota = enforceQuota;
7
+ const fs_extra_1 = __importDefault(require("fs-extra"));
8
+ const path_1 = __importDefault(require("path"));
9
+ /**
10
+ * Ring-buffer retention at file granularity: when the total size of the
11
+ * session files exceeds the quota, the OLDEST session files are deleted
12
+ * until the directory is under the cap. The agent only sees sessions
13
+ * that still exist — deleted files are simply absent, never an error.
14
+ */
15
+ function enforceQuota(sessionsDir, maxBytes) {
16
+ const deleted = [];
17
+ if (!fs_extra_1.default.existsSync(sessionsDir) || maxBytes <= 0)
18
+ return deleted;
19
+ const entries = fs_extra_1.default
20
+ .readdirSync(sessionsDir)
21
+ .filter((f) => f.endsWith('.jsonl'))
22
+ .map((f) => {
23
+ const file = path_1.default.join(sessionsDir, f);
24
+ const stat = fs_extra_1.default.statSync(file);
25
+ return { file, size: stat.size, mtime: stat.mtimeMs };
26
+ });
27
+ let total = entries.reduce((sum, e) => sum + e.size, 0);
28
+ if (total <= maxBytes)
29
+ return deleted;
30
+ // Oldest first (mtime ascending) — classic ring-buffer eviction order.
31
+ entries.sort((a, b) => a.mtime - b.mtime);
32
+ for (const entry of entries) {
33
+ if (total <= maxBytes)
34
+ break;
35
+ fs_extra_1.default.unlinkSync(entry.file);
36
+ total -= entry.size;
37
+ deleted.push(entry.file);
38
+ }
39
+ return deleted;
40
+ }
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.recorderSessionsDir = recorderSessionsDir;
7
+ exports.resolveRecorderDist = resolveRecorderDist;
8
+ exports.createRecorderRouter = createRecorderRouter;
9
+ const express_1 = __importDefault(require("express"));
10
+ const fs_extra_1 = __importDefault(require("fs-extra"));
11
+ const os_1 = __importDefault(require("os"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const collector_1 = require("./collector");
14
+ const retention_1 = require("./retention");
15
+ /**
16
+ * Recorder routes, mounted at /recorder on the daemon's existing port
17
+ * (no separate listener, no CORS — the pane is same-origin):
18
+ * POST /recorder/events batched event upload from the browser bundle
19
+ * GET /recorder/recorder.js the static IIFE bundle
20
+ */
21
+ const DEFAULT_QUOTA_BYTES = 100 * 1024 * 1024;
22
+ /** Directory holding the JSONL session files. */
23
+ function recorderSessionsDir(home = os_1.default.homedir()) {
24
+ return path_1.default.join(home, '.repoburg', 'recorder', 'sessions');
25
+ }
26
+ /**
27
+ * Resolve the recorder bundle ABSOLUTELY (pm2 God-daemon-cwd bug class:
28
+ * never resolve runtime paths against the process cwd).
29
+ * Works for dev (daemon/src or daemon/dist) and the published layout
30
+ * (repoburg/daemon/dist -> repoburg/recorder/dist).
31
+ */
32
+ function resolveRecorderDist(fromDir) {
33
+ return path_1.default.resolve(fromDir, '../../recorder/dist');
34
+ }
35
+ function createRecorderRouter(opts = {}) {
36
+ const sessionsDir = opts.sessionsDir ?? recorderSessionsDir();
37
+ const maxBytes = opts.maxBytes ??
38
+ (process.env.REPOBURG_RECORDER_MAX_BYTES
39
+ ? parseInt(process.env.REPOBURG_RECORDER_MAX_BYTES, 10)
40
+ : DEFAULT_QUOTA_BYTES);
41
+ const router = express_1.default.Router();
42
+ // Batched upload: { sid, events: [...] } -> append to the session file.
43
+ router.post('/events', (req, res) => {
44
+ try {
45
+ const sid = (0, collector_1.validateSid)(req.body?.sid);
46
+ const events = (0, collector_1.validateEvents)(req.body?.events);
47
+ fs_extra_1.default.ensureDirSync(sessionsDir);
48
+ (0, collector_1.appendSessionEvents)(sessionsDir, sid, events);
49
+ (0, retention_1.enforceQuota)(sessionsDir, maxBytes);
50
+ res.status(204).end();
51
+ }
52
+ catch (err) {
53
+ res.status(400).json({ error: err.message });
54
+ }
55
+ });
56
+ // Static bundle for the pane's one-line <script> include.
57
+ // Default path is relative to THIS module (daemon/[src|dist]/recorder) —
58
+ // three levels up to the repo/published root. index.ts passes its own
59
+ // resolution (two levels from daemon/[src|dist]) explicitly.
60
+ const distPath = opts.distPath ?? path_1.default.resolve(__dirname, '../../../recorder/dist');
61
+ if (fs_extra_1.default.existsSync(distPath)) {
62
+ router.use(express_1.default.static(distPath));
63
+ }
64
+ else {
65
+ console.warn(`[recorder] bundle not found at ${distPath} — script not served`);
66
+ }
67
+ return router;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repoburg",
3
- "version": "1.3.227",
3
+ "version": "1.3.229",
4
4
  "description": "A local AI-powered software developer assistant that runs on your own machine.",
5
5
  "author": "Celal Ertug",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -12,7 +12,8 @@
12
12
  "backend",
13
13
  "website2",
14
14
  "daemon",
15
- "visual-editor-proxy"
15
+ "visual-editor-proxy",
16
+ "recorder"
16
17
  ],
17
18
  "bin": {
18
19
  "repoburg": "./platform-cli.js",
@@ -37,7 +38,7 @@
37
38
  "dev:agent-pane": "concurrently -n backend,frontend -c blue,green \"cd agent-pane/backend && npm run dev\" \"cd agent-pane/frontend && npm run dev\"",
38
39
  "jaeger:start": "docker run -d --rm --name jaeger -e COLLECTOR_OTLP_ENABLED=true -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:latest",
39
40
  "jaeger:stop": "docker stop jaeger",
40
- "lo:build": "npm run build -w backend && npm run build -w website2 && (cd agent-pane/backend && npm run build) && (cd agent-pane/frontend && npm run build) && npm run build -w daemon && cd frontend && npm run build",
41
+ "lo:build": "npm run build -w backend && npm run build -w website2 && (cd agent-pane/backend && npm run build) && (cd agent-pane/frontend && npm run build) && npm run build -w recorder && npm run build -w daemon && cd frontend && npm run build",
41
42
  "lo:publish": "npm publish"
42
43
  },
43
44
  "dependencies": {
@@ -0,0 +1 @@
1
+ "use strict";(()=>{function l(t,r){return(e,o={})=>{let i={ts:Date.now(),t:Date.now()-t.t0,sid:t.sid,type:e,...o};try{r.push(i)}catch{}}}function S(t){if(!(t instanceof Element))return"";if(t.id)return`#${t.id}`;let r=t.tagName.toLowerCase(),e=t.classList.length?"."+Array.from(t.classList).slice(0,2).join("."):"";return r+e}function f(t){let r=e=>{t("click",{target:S(e.target)})};return document.addEventListener("click",r,!0),()=>document.removeEventListener("click",r,!0)}function m(t,r=500){let e=0,o=null,i=new MutationObserver(n=>{e+=n.length,o===null&&(o=setTimeout(()=>{o=null;let c=e;e=0,c>0&&t("dom_update",{count:c})},r))});try{i.observe(document.documentElement,{childList:!0,subtree:!0})}catch{}return()=>{o!==null&&clearTimeout(o),i.disconnect()}}function h(t){let r=o=>{t("error",{message:o.message})},e=o=>{t("error",{message:String(o.reason?.message??o.reason)})};return window.addEventListener("error",r),window.addEventListener("unhandledrejection",e),()=>{window.removeEventListener("error",r),window.removeEventListener("unhandledrejection",e)}}var T=0,v=()=>`r${++T}`,y=t=>t.includes("/recorder/events");function I(t){return typeof t=="string"?t:t instanceof URL?t.href:t.url}function E(t){let r=[],e=window.fetch.bind(window);window.fetch=(n,c)=>{let a=I(n);if(y(a))return e(n,c);let s=(c?.method??"GET").toUpperCase(),u=v(),d=Date.now();return t("request_start",{reqId:u,method:s,url:a}),e(n,c).then(p=>(t("request_end",{reqId:u,method:s,url:a,status:p.status,dur:Date.now()-d}),p),p=>{throw t("request_fail",{reqId:u,method:s,url:a,message:String(p?.message??p),dur:Date.now()-d}),p})},r.push(()=>{window.fetch=e});let o=XMLHttpRequest.prototype.open,i=XMLHttpRequest.prototype.send;return XMLHttpRequest.prototype.open=function(n,c,...a){return this.__recMethod=String(n).toUpperCase(),this.__recUrl=String(c),o.apply(this,[n,c,...a])},XMLHttpRequest.prototype.send=function(...n){if(y(this.__recUrl??""))return i.apply(this,n);let c=v(),a=this.__recMethod??"GET",s=this.__recUrl??"",u=Date.now();return t("request_start",{reqId:c,method:a,url:s}),this.addEventListener("loadend",()=>{let d=Date.now()-u;this.status===0||this.status>=400?t("request_fail",{reqId:c,method:a,url:s,status:this.status,dur:d}):t("request_end",{reqId:c,method:a,url:s,status:this.status,dur:d})}),i.apply(this,n)},r.push(()=>{XMLHttpRequest.prototype.open=o,XMLHttpRequest.prototype.send=i}),()=>r.forEach(n=>n())}function g(t){let r=[],e=(i,n,c)=>{try{let a=new PerformanceObserver(s=>{for(let u of s.getEntries())n(u)});a.observe({type:i,buffered:!0,...c}),r.push(a)}catch{}};e("longtask",i=>t("long_task",{dur:Math.round(i.duration)})),e("largest-contentful-paint",i=>t("lcp",{value:Math.round(i.startTime)}));let o=new Set;return e("event",i=>{let n=i;n.duration<50||!n.interactionId||o.has(n.interactionId)||(o.size>=1e3&&o.clear(),o.add(n.interactionId),t("inp",{value:Math.round(n.duration)}))},{durationThreshold:16}),e("layout-shift",i=>{let n=i;n.hadRecentInput||t("cls",{value:Math.round(n.value*1e3)/1e3})}),()=>r.forEach(i=>{try{i.disconnect()}catch{}})}function w(t){let r=()=>t("route_change",{route:location.pathname}),e=history.pushState.bind(history),o=history.replaceState.bind(history),i=(...a)=>{let s=e(...a);return r(),s},n=(...a)=>{let s=o(...a);return r(),s};history.pushState=i,history.replaceState=n;let c=()=>r();return window.addEventListener("popstate",c),()=>{history.pushState=e,history.replaceState=o,window.removeEventListener("popstate",c)}}function R(t,r=1e3){let e=0,o=null,i=()=>{e=Date.now(),t("scroll",{y:Math.round(window.scrollY)})},n=()=>{if(Date.now()-e<r){o===null&&(o=setTimeout(()=>{o=null,i()},r));return}i()};return window.addEventListener("scroll",n,{passive:!0}),()=>{o!==null&&clearTimeout(o),window.removeEventListener("scroll",n)}}function q(){try{return crypto.randomUUID().replace(/-/g,"").slice(0,12)}catch{return Array.from({length:12},()=>Math.floor(Math.random()*36).toString(36)).join("")}}function b(){return{sid:q(),t0:Date.now()}}var _="/recorder/events";function L(t,r={}){let e=r.fetch??((...s)=>fetch(...s)),o=r.sendBeacon??((s,u)=>{try{return navigator.sendBeacon(s,u)}catch{return!1}}),i=r.onUnload??(s=>{document.addEventListener("pagehide",s),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"&&s()})}),n=[],c=(s,u)=>{if(s.length===0)return;let d=JSON.stringify({sid:t,events:s});try{if(u&&o(_,d))return;e(_,{method:"POST",headers:{"content-type":"application/json"},body:d,keepalive:!0}).catch(()=>{})}catch{}},a={push(s){n.push(s),n.length>=100&&a.flush()},flush(s=!1){let u=n;n=[],c(u,s)}};return(r.setInterval??((s,u)=>setInterval(s,u)))(()=>a.flush(),2e3),i(()=>a.flush(!0)),a}(function(){let r=window;if(!r.__repoburgRecorder){r.__repoburgRecorder=!0;try{M()}catch{}}})();function M(){let t=b(),r=L(t.sid),e=l(t,r);e("session_start",{route:location.pathname}),w(e),f(e),R(e),m(e),E(e),h(e),g(e),document.addEventListener("pagehide",()=>{e("session_end"),r.flush(!0)})}})();