{esc(r['visibility'])} · {r['stars_count']}★"
+ for r in repos
+ ) or "
No repositories yet.
"
+ body = f"""
+
+
+
GitVault
+
A GitHub-inspired code collaboration platform with real Git repositories, issues, pull requests, Actions, packages, pages, orgs, search, admin, and more.
"
+ return render_page("Marketplace", body, user)
+
+ @app.get("/sponsors", response_class=HTMLResponse)
+ def sponsors_page(request: Request) -> HTMLResponse:
+ user = current_user(request)
+ tiers = db.list_sponsorship_tiers(user["username"] if user else "") if user else []
+ if user and not tiers:
+ db.create_sponsorship_tier(user["username"], "Supporter", 500, "Back the roadmap")
+ tiers = db.list_sponsorship_tiers(user["username"])
+ body = f"
Sponsors
User and organization sponsorship tiers with payment-ready records.
${workspaceRole === 'owner' ? 'Owner controls for sharing, enterprise identity, and provisioning.' : 'Read-only audit and identity overview for this workspace.'}
');
+ if (page.share.allowedDomain) {
+ const viewer = sessionInfo?.user;
+ if (!viewer || !viewer.email.endsWith(`@${page.share.allowedDomain}`)) return html(res, 403, `
Access restricted to ${escapeHtml(page.share.allowedDomain)}
`);
+ }
+ return html(res, 200, renderPublicPage(page, data));
+ }
+
+ if (pathname === '/' || pathname === '/index.html') {
+ return serveStatic(res, path.join(PUBLIC_DIR, 'index.html'));
+ }
+
+ if (serveStatic(res, path.join(PUBLIC_DIR, pathname))) return;
+ return redirect(res, '/');
+ } catch (error) {
+ console.error(error);
+ return json(res, 500, { error: error.message, stack: error.stack });
+ }
+});
+
+if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
+ server.listen(PORT, () => {
+ console.log(`NoteFlow running at http://localhost:${PORT}`);
+ });
+}
+
+export {
+ defaultData,
+ markdownToBlocks,
+ htmlToBlocks,
+ csvToTasks,
+ blocksToMarkdown,
+ buildSimplePdf,
+ search,
+ normalizeBlocks,
+ server,
+};
diff --git a/deliverable/noteflow/tasks/changedoc.md b/deliverable/noteflow/tasks/changedoc.md
new file mode 100644
index 000000000..69a3fb0d1
--- /dev/null
+++ b/deliverable/noteflow/tasks/changedoc.md
@@ -0,0 +1,51 @@
+# Change Document
+
+**Sources reviewed:** [agent1.2]
+
+## Summary
+Delivered the final NoteFlow app as a zero-dependency full-stack Node.js workspace platform with a browser SPA, seeded demo data, tests, and an enterprise collaboration/admin layer. The final delivery keeps the broad Notion-style surface from the prior base and includes workspace/page management, a block editor, databases, tasks, search, files, notifications, authentication, import/export, realtime presence, and admin/governance workflows such as SSO/SAML demo auth, SCIM provisioning, invitation acceptance, audit activity, 2FA setup, and page/database permission management.
+
+## Decisions
+
+### DEC-001: Keep the zero-dependency full-stack Node delivery from agent1.1/agent1.2
+**Origin:** agent1.2 (kept)
+**Choice:** Retain the single-process Node server plus static SPA architecture.
+**Why:** The task required a very broad product surface in one deliverable. This architecture keeps startup friction low, ships cleanly with built-in Node APIs only, and lets the app cover pages, databases, tasks, search, files, auth, and collaboration in one runnable project.
+**Alternatives considered:**
+- Replatform to a heavier framework: rejected because it would reduce shipped scope and increase setup complexity for this delivery.
+**Implementation:**
+- `server.mjs` → HTTP server, routing, persistence, SSE presence/events, auth/session handling, import/export helpers, search, file APIs, workspace/page/database/task endpoints
+- `public/index.html` → SPA shell
+- `public/styles.css` → application layout and feature styling
+- `public/app.js` → client-side state management, page editor UI, databases/tasks/search/files/notifications/admin experiences
+- `package.json` → runnable start/dev/test scripts
+
+### DEC-002: Preserve first-class databases and extend them with permission management rather than replacing the model
+**Origin:** agent1.2 (kept) → [SELF] (modified)
+**Choice:** Keep databases/rows/views as a core primitive and extend them with admin-managed access grants.
+**Why:** Notion-style collaboration depends on structured databases being a first-class workspace object, not an afterthought. Extending the existing database model with governance controls closes a major gap between a document app and a real team workspace platform.
+**Alternatives considered:**
+- Leave databases unchanged: rejected because it would leave collaboration and governance incomplete for team use.
+**Implementation:**
+- `server.mjs` → database CRUD routes, row CRUD routes, workspace settings, permission-bearing database records returned through `/api/bootstrap`
+- `public/app.js` → database rendering, row editing, view switching, and `renderAdminPanel()` flows for database permission grants
+- `tests/noteflow.test.mjs` → database creation, row update, search, export, and enterprise coverage regression tests
+
+### DEC-003: Add an enterprise admin/security slice as the highest-leverage missing product area
+**Origin:** [SELF] — NEW
+**Choice:** Introduce workspace settings, audit feed, invitation lifecycle visibility, SSO/SAML demo login, SCIM provisioning, device/session metadata, 2FA activation, and explicit page/database permission grants.
+**Why:** The broad collaboration surface was already strong, but the largest remaining mismatch with the original brief was enterprise governance and identity administration. Making these flows visible and usable in both API and UI materially improves fidelity to the requested NoteFlow platform.
+**Alternatives considered:**
+- Keep enterprise behaviors implicit or backend-only: rejected because the brief asked for production-style collaboration, identity, and permissions features that should be operable from the product.
+**Implementation:**
+- `server.mjs` → `ensureAuthMethod()`, `upsertWorkspaceMember()`, `acceptPendingInvitations()`, `/api/auth/sso`, `/api/workspaces/:id/settings`, `/api/workspaces/:id/invitations`, `/api/workspaces/:id/activity`, `/api/workspaces/:id/scim/users`, session/device metadata, audit logging, and sharing control enforcement
+- `public/app.js` → `renderAdminPanel()`, workspace settings form, SSO entry flow, SCIM provisioning form, invitation tracking, page/database permission forms, activity feed rendering, and `setupTwoFactor()`
+- `README.md` → shipped feature summary including enterprise admin/security capabilities
+- `tests/noteflow.test.mjs` → regression coverage for workspace settings, SSO invite acceptance, audit logging, and SCIM membership sync
+
+## Deliberation Trail
+
+### [SELF] (synthesized from agent1.2)
+- DEC-001: Kept the existing zero-dependency Node architecture because it remained the fastest way to preserve breadth and ship a fully runnable product.
+- DEC-002: Kept first-class databases from agent1.2 and extended them so governance applies to databases as well as pages.
+- DEC-003: NEW — added the enterprise collaboration/admin slice because it was the clearest remaining gap against the original NoteFlow brief.
diff --git a/deliverable/noteflow/tests/noteflow.test.mjs b/deliverable/noteflow/tests/noteflow.test.mjs
new file mode 100644
index 000000000..c33e689b0
--- /dev/null
+++ b/deliverable/noteflow/tests/noteflow.test.mjs
@@ -0,0 +1,428 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { setTimeout as delay } from 'node:timers/promises';
+
+import {
+ blocksToMarkdown,
+ buildSimplePdf,
+ csvToTasks,
+ defaultData,
+ htmlToBlocks,
+ markdownToBlocks,
+ search,
+ server,
+} from '../server.mjs';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const DATA_FILE = path.join(ROOT, 'data', 'noteflow-db.json');
+const UPLOAD_DIR = path.join(ROOT, 'uploads');
+const PORT = 3107;
+const BASE = `http://127.0.0.1:${PORT}`;
+
+function cleanupRuntimeFiles() {
+ if (fs.existsSync(DATA_FILE)) fs.rmSync(DATA_FILE, { force: true });
+ if (fs.existsSync(UPLOAD_DIR)) {
+ for (const file of fs.readdirSync(UPLOAD_DIR)) fs.rmSync(path.join(UPLOAD_DIR, file), { force: true });
+ }
+}
+
+async function waitForServer() {
+ for (let i = 0; i < 50; i += 1) {
+ try {
+ const response = await fetch(`${BASE}/api/bootstrap`);
+ if (response.ok) return;
+ } catch {}
+ await delay(100);
+ }
+ throw new Error('Server did not start in time');
+}
+
+test('content helpers parse and export blocks', () => {
+ const blocks = markdownToBlocks('# Title\n\n- [ ] Todo\n> Quote');
+ assert.equal(blocks[0].type, 'heading1');
+ assert.equal(blocks[1].type, 'divider');
+ assert.equal(blocks[2].type, 'todo');
+ const htmlBlocks = htmlToBlocks('
+ Demo day ${state.currentDay} (${getDayLabel(state)})
+ Persistent state stored in your browser
+ One tap only every action waits for explicit approval
+
+
StayFlow Agents
+
+ A working short-stay marketplace prototype where the Guest Agent and Host Agent both monitor,
+ detect, propose, wait for explicit human approval, act immediately after approval, and confirm
+ the result. Agent-to-agent negotiation, disputes, pricing, turnovers, reviews, and repeat state
+ all live in one persistent demo.
+
+
+
+
+
+
+ `;
+}
+
+function splitList(value) {
+ return value
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean);
+}
+
+function fastForwardLifecycle() {
+ const pendingGuest = getProposalQueue(state, 'guest').filter((proposal) => proposal.action.type === 'create_inquiry');
+ pendingGuest.forEach((proposal) => approveProposal(state, proposal.id));
+ runAllAgentLoops(state);
+
+ getProposalQueue(state, 'host').forEach((proposal) => {
+ if (proposal.action.type === 'counter_inquiry') approveProposal(state, proposal.id);
+ });
+ runAllAgentLoops(state);
+
+ getProposalQueue(state, 'guest').forEach((proposal) => {
+ if (proposal.action.type === 'accept_counter') approveProposal(state, proposal.id);
+ });
+
+ if (state.bookings[0]) {
+ if (state.currentDay < state.bookings[0].startDay) {
+ advanceDay(state, state.bookings[0].startDay - state.currentDay);
+ }
+ runAllAgentLoops(state);
+ }
+
+ commit('Fast-forwarded seeded trips to the first check-in window.');
+}
+
+app.addEventListener('click', (event) => {
+ const button = event.target.closest('button[data-action]');
+ if (!button) return;
+ const { action, id } = button.dataset;
+
+ if (action === 'approve-proposal') {
+ approveProposal(state, id);
+ commit('Proposal approved and executed.');
+ return;
+ }
+
+ if (action === 'reject-proposal') {
+ rejectProposal(state, id);
+ commit('Proposal rejected. The agent loop will respond with a revised next step.');
+ return;
+ }
+
+ if (action === 'run-loops') {
+ runAllAgentLoops(state);
+ commit('Ran monitor → detect → propose across guest, host, and admin agents.', false);
+ return;
+ }
+
+ if (action === 'advance-day') {
+ advanceDay(state, 1);
+ commit(`Advanced to day ${state.currentDay}.`);
+ return;
+ }
+
+ if (action === 'toggle-auto') {
+ uiState.autoMonitor = !uiState.autoMonitor;
+ render();
+ return;
+ }
+
+ if (action === 'reset-state') {
+ resetState();
+ return;
+ }
+
+ if (action === 'seed-full-lifecycle') {
+ fastForwardLifecycle();
+ return;
+ }
+
+ if (action === 'toggle-listing') {
+ const listing = state.listings.find((candidate) => candidate.id === id);
+ listing.active = !listing.active;
+ commit(`${listing.title} is now ${listing.active ? 'active' : 'inactive'}.`, false);
+ return;
+ }
+
+ if (action === 'cycle-strategy') {
+ const listing = state.listings.find((candidate) => candidate.id === id);
+ const modes = ['normal', 'underbooked', 'premium'];
+ const index = modes.indexOf(listing.availabilityStrategy);
+ listing.availabilityStrategy = modes[(index + 1) % modes.length];
+ commit(`${listing.title} availability strategy is now ${listing.availabilityStrategy}.`, true);
+ }
+});
+
+app.addEventListener('submit', (event) => {
+ event.preventDefault();
+ const form = event.target;
+
+ if (form.id === 'trip-form') {
+ const data = new FormData(form);
+ addTripRequest(state, {
+ guestId: state.guests[0].id,
+ label: data.get('label'),
+ city: data.get('city'),
+ startDay: data.get('startDay'),
+ endDay: data.get('endDay'),
+ budget: data.get('budget'),
+ partySize: data.get('partySize'),
+ vibe: data.get('vibe'),
+ mustHave: splitList(data.get('mustHave') || ''),
+ checkInPreference: data.get('checkInPreference'),
+ specialRequest: data.get('specialRequest'),
+ });
+ form.reset();
+ commit('Added a new trip request and re-ran the guest agent for matching.', true);
+ return;
+ }
+
+ if (form.id === 'listing-form') {
+ const data = new FormData(form);
+ addListing(state, {
+ hostId: data.get('hostId'),
+ title: data.get('title'),
+ city: data.get('city'),
+ nightlyRate: data.get('nightlyRate'),
+ cleaningFee: data.get('cleaningFee'),
+ maxGuests: data.get('maxGuests'),
+ tags: splitList(data.get('tags') || ''),
+ features: splitList(data.get('features') || ''),
+ });
+ form.reset();
+ commit('Listing created and host portfolio updated.', true);
+ return;
+ }
+
+ if (form.id === 'rules-form') {
+ const data = new FormData(form);
+ state.rules.disputeCreditCap = Number(data.get('disputeCreditCap'));
+ commit('Admin rule updated for future dispute proposals.', false);
+ return;
+ }
+
+ if (form.dataset.action === 'report-issue') {
+ const data = new FormData(form);
+ reportStayIssue(state, {
+ bookingId: form.dataset.bookingId,
+ summary: data.get('summary'),
+ severity: data.get('severity'),
+ });
+ commit('Reported an in-stay issue and queued the host response.', true);
+ }
+});
+
+app.addEventListener('input', (event) => {
+ const form = event.target.closest('#search-form');
+ if (!form) return;
+ const data = new FormData(form);
+ uiState.searchCriteria = {
+ city: data.get('city') || '',
+ maxNightlyRate: data.get('maxNightlyRate') || '',
+ partySize: data.get('partySize') || '',
+ };
+ render();
+});
+
+window.setInterval(() => {
+ if (!uiState.autoMonitor) return;
+ const before = getProposalQueue(state).length;
+ runAllAgentLoops(state);
+ const after = getProposalQueue(state).length;
+ uiState.lastAutoScan = Date.now();
+ if (after !== before) {
+ saveState('Auto-monitor detected a new action and queued it for approval.');
+ render();
+ }
+}, 12000);
+
+saveState('Prototype loaded. Every action still requires one-tap human approval.');
+render();
diff --git a/deliverable/stayflow_agents/deliverable/index.html b/deliverable/stayflow_agents/deliverable/index.html
new file mode 100644
index 000000000..c4ffb27f6
--- /dev/null
+++ b/deliverable/stayflow_agents/deliverable/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ StayFlow Agents — Rental Marketplace Prototype
+
+
+
+
+
+
+
diff --git a/deliverable/stayflow_agents/deliverable/state-engine.mjs b/deliverable/stayflow_agents/deliverable/state-engine.mjs
new file mode 100644
index 000000000..511a0463e
--- /dev/null
+++ b/deliverable/stayflow_agents/deliverable/state-engine.mjs
@@ -0,0 +1,908 @@
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+function deepClone(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+function addDays(baseDate, dayOffset) {
+ const base = new Date(baseDate);
+ base.setUTCDate(base.getUTCDate() + dayOffset);
+ return base.toISOString().slice(0, 10);
+}
+
+function nightlyTotal(booking) {
+ return (booking.nightlyRate || 0) * ((booking.endDay || 0) - (booking.startDay || 0));
+}
+
+function buildLoopTrace({ actor, monitor, detect, propose }) {
+ return {
+ actor,
+ monitor,
+ detect,
+ propose,
+ approve: 'Awaiting one-tap human approval.',
+ act: 'Action will execute immediately after approval.',
+ confirm: 'State and messages will update on success.',
+ };
+}
+
+function ensureCounters(state) {
+ state.counters ||= { proposal: 1, booking: 1, message: 1, listing: 100, dispute: 1, adjustment: 1, review: 1, trip: 100 };
+ return state.counters;
+}
+
+function nextId(state, prefix) {
+ const counters = ensureCounters(state);
+ counters[prefix] ||= 1;
+ const id = `${prefix}-${counters[prefix]++}`;
+ return id;
+}
+
+function logActivity(state, type, message, meta = {}) {
+ state.activityLog.unshift({
+ id: nextId(state, 'message'),
+ day: state.currentDay,
+ type,
+ message,
+ meta,
+ at: Date.now() + Math.floor(Math.random() * 1000),
+ });
+}
+
+function getGuest(state, guestId) {
+ return state.guests.find((guest) => guest.id === guestId);
+}
+
+function getHost(state, hostId) {
+ return state.hosts.find((host) => host.id === hostId);
+}
+
+function getListing(state, listingId) {
+ return state.listings.find((listing) => listing.id === listingId);
+}
+
+function getBooking(state, bookingId) {
+ return state.bookings.find((booking) => booking.id === bookingId);
+}
+
+function getTripRequest(state, tripRequestId) {
+ for (const guest of state.guests) {
+ const found = guest.tripRequests.find((trip) => trip.id === tripRequestId);
+ if (found) return found;
+ }
+ return undefined;
+}
+
+function proposalExists(state, matcher) {
+ return state.proposals.some((proposal) => proposal.status === 'pending' && matcher(proposal));
+}
+
+function bookingForTrip(state, tripRequestId) {
+ return state.bookings.find((booking) => booking.tripRequestId === tripRequestId && !['cancelled', 'completed'].includes(booking.stage));
+}
+
+function scoreListing(tripRequest, listing) {
+ let score = 0;
+ if (tripRequest.city === listing.city) score += 5;
+ score += Math.max(0, 4 - Math.abs((tripRequest.budget || 0) - listing.nightlyRate) / 50);
+ if ((listing.maxGuests || 1) >= (tripRequest.partySize || 1)) score += 2;
+ for (const feature of tripRequest.mustHave || []) {
+ if (listing.features.includes(feature)) score += 1.5;
+ }
+ if (listing.tags.includes(tripRequest.vibe)) score += 1.25;
+ return score;
+}
+
+function matchTripToListing(state, tripRequest) {
+ const candidates = state.listings
+ .filter((listing) => listing.city === tripRequest.city && listing.active !== false)
+ .map((listing) => ({ listing, score: scoreListing(tripRequest, listing) }))
+ .sort((a, b) => b.score - a.score);
+ return candidates[0]?.listing;
+}
+
+function createProposal(state, draft) {
+ const proposal = {
+ id: nextId(state, 'proposal'),
+ createdDay: state.currentDay,
+ status: 'pending',
+ ...draft,
+ };
+ state.proposals.unshift(proposal);
+ logActivity(
+ state,
+ 'proposal_created',
+ `${proposal.agentName} proposed an action for ${proposal.humanName}: ${proposal.title}`,
+ { proposalId: proposal.id, humanRole: proposal.humanRole, bookingId: proposal.bookingId || null },
+ );
+ return proposal;
+}
+
+function addThreadMessage(state, booking, from, text, visibility = ['guest', 'host']) {
+ booking.thread ||= [];
+ booking.thread.push({
+ id: nextId(state, 'message'),
+ day: state.currentDay,
+ from,
+ text,
+ visibility,
+ });
+}
+
+function calculatePayout(booking, listing) {
+ const nights = Math.max(1, booking.endDay - booking.startDay);
+ return booking.nightlyRate * nights + (listing.cleaningFee || 0);
+}
+
+function createBookingFromTrip(state, tripRequest, listing) {
+ const booking = {
+ id: nextId(state, 'booking'),
+ guestId: tripRequest.guestId,
+ hostId: listing.hostId,
+ listingId: listing.id,
+ tripRequestId: tripRequest.id,
+ stage: 'inquiry',
+ lifecycle: 'inquiry',
+ startDay: tripRequest.startDay,
+ endDay: tripRequest.endDay,
+ requestedCheckIn: tripRequest.checkInPreference,
+ proposedNightlyRate: Math.min(tripRequest.budget, listing.nightlyRate),
+ nightlyRate: listing.nightlyRate,
+ total: listing.nightlyRate * (tripRequest.endDay - tripRequest.startDay),
+ partySize: tripRequest.partySize,
+ specialRequest: tripRequest.specialRequest,
+ thread: [],
+ issue: null,
+ dispute: { status: 'none' },
+ financials: { payout: 0, adjustments: [] },
+ review: { guestSubmitted: false, hostSubmitted: false },
+ turnover: { status: 'pending' },
+ };
+ state.bookings.push(booking);
+ tripRequest.status = 'inquiry_sent';
+ tripRequest.bookingId = booking.id;
+ addThreadMessage(
+ state,
+ booking,
+ 'guestAgent',
+ `Inquiry created for ${listing.title}. Requested ${tripRequest.checkInPreference} check-in and noted: ${tripRequest.specialRequest}.`,
+ );
+ addThreadMessage(
+ state,
+ booking,
+ 'system',
+ `Both humans can review this plain-language thread before approving next actions.`,
+ );
+ logActivity(state, 'booking_created', `Guest inquiry opened for ${listing.title}.`, { bookingId: booking.id, listingId: listing.id });
+ return booking;
+}
+
+function addAdjustment(state, booking, amount, reason, actor = 'admin') {
+ booking.financials.adjustments.push({
+ id: nextId(state, 'adjustment'),
+ amount,
+ reason,
+ actor,
+ day: state.currentDay,
+ });
+}
+
+function approveGuestBookingProposal(state, proposal) {
+ const tripRequest = getTripRequest(state, proposal.action.tripRequestId);
+ const listing = getListing(state, proposal.action.listingId);
+ if (!tripRequest || !listing) return;
+ const booking = createBookingFromTrip(state, tripRequest, listing);
+ booking.humanApprovalHistory = [{ by: 'guest', decision: 'approved', proposalId: proposal.id, day: state.currentDay }];
+ logActivity(state, 'proposal_approved', `Guest approved booking inquiry for ${listing.title}.`, { proposalId: proposal.id, bookingId: booking.id });
+}
+
+function approveHostCounterProposal(state, proposal) {
+ const booking = getBooking(state, proposal.bookingId);
+ const listing = getListing(state, booking?.listingId);
+ if (!booking || !listing) return;
+ const counterNightlyRate = proposal.action.counterNightlyRate;
+ booking.stage = 'negotiating';
+ booking.lifecycle = 'negotiation';
+ booking.pendingCounter = {
+ nightlyRate: counterNightlyRate,
+ checkInTime: proposal.action.checkInTime,
+ note: proposal.action.note,
+ };
+ addThreadMessage(
+ state,
+ booking,
+ 'hostAgent',
+ `Counter-offer: ${proposal.action.note} ${proposal.action.checkInTime} check-in at $${counterNightlyRate}/night.`,
+ );
+ booking.humanApprovalHistory ||= [];
+ booking.humanApprovalHistory.push({ by: 'host', decision: 'approved', proposalId: proposal.id, day: state.currentDay });
+ logActivity(state, 'proposal_approved', `Host approved a counter-offer for booking ${booking.id}.`, { proposalId: proposal.id, bookingId: booking.id });
+}
+
+function approveGuestCounterAcceptance(state, proposal) {
+ const booking = getBooking(state, proposal.bookingId);
+ const listing = getListing(state, booking?.listingId);
+ if (!booking || !listing || !booking.pendingCounter) return;
+ booking.stage = 'booked';
+ booking.lifecycle = 'booked';
+ booking.nightlyRate = booking.pendingCounter.nightlyRate;
+ booking.confirmedCheckIn = booking.pendingCounter.checkInTime;
+ booking.total = booking.nightlyRate * (booking.endDay - booking.startDay);
+ booking.financials.payout = calculatePayout(booking, listing);
+ booking.pendingCounter = null;
+ const tripRequest = getTripRequest(state, booking.tripRequestId);
+ if (tripRequest) tripRequest.status = 'booked';
+ addThreadMessage(
+ state,
+ booking,
+ 'guestAgent',
+ `Guest accepted the host counter-offer. Booking is now confirmed.`,
+ );
+ logActivity(state, 'booking_confirmed', `Booking ${booking.id} is confirmed.`, { bookingId: booking.id });
+}
+
+function approveCheckIn(state, proposal) {
+ const booking = getBooking(state, proposal.bookingId);
+ if (!booking) return;
+ booking.stage = 'checked_in';
+ booking.lifecycle = 'stay';
+ booking.checkedInDay = state.currentDay;
+ addThreadMessage(state, booking, 'guestAgent', `Guest checked in using the approved arrival plan.`, ['guest', 'host']);
+ logActivity(state, 'check_in', `Guest checked in for booking ${booking.id}.`, { bookingId: booking.id });
+}
+
+function approveIssueResolution(state, proposal) {
+ const booking = getBooking(state, proposal.bookingId);
+ if (!booking || !booking.issue) return;
+ booking.issue.status = 'resolved';
+ booking.issue.resolvedDay = state.currentDay;
+ booking.issue.resolution = proposal.action.resolution;
+ addAdjustment(state, booking, proposal.action.creditAmount, proposal.action.resolution, 'host');
+ addThreadMessage(state, booking, 'hostAgent', `Resolution applied: ${proposal.action.resolution}. Credit: $${proposal.action.creditAmount}.`);
+ logActivity(state, 'issue_resolved', `Host resolved issue for booking ${booking.id}.`, { bookingId: booking.id });
+}
+
+function approveDisputeEscalation(state, proposal) {
+ const booking = getBooking(state, proposal.bookingId);
+ if (!booking || !booking.issue) return;
+ booking.dispute = {
+ id: nextId(state, 'dispute'),
+ status: 'open',
+ openedDay: state.currentDay,
+ summary: booking.issue.summary,
+ };
+ booking.issue.status = 'escalated';
+ addThreadMessage(state, booking, 'guestAgent', 'Escalating the unresolved issue to marketplace admin for mediation.');
+ logActivity(state, 'dispute_opened', `Guest escalated booking ${booking.id} to admin.`, { bookingId: booking.id, disputeId: booking.dispute.id });
+}
+
+function approveAdminResolution(state, proposal) {
+ const booking = getBooking(state, proposal.bookingId);
+ if (!booking || !booking.issue) return;
+ booking.dispute.status = 'closed';
+ booking.dispute.closedDay = state.currentDay;
+ booking.issue.status = 'resolved';
+ booking.issue.resolution = proposal.action.resolution;
+ booking.issue.resolvedDay = state.currentDay;
+ addAdjustment(state, booking, proposal.action.creditAmount, proposal.action.resolution, 'admin');
+ addThreadMessage(state, booking, 'admin', `Admin mediation closed the dispute: ${proposal.action.resolution}. Credit approved: $${proposal.action.creditAmount}.`);
+ logActivity(state, 'dispute_closed', `Admin resolved dispute for booking ${booking.id}.`, { bookingId: booking.id });
+}
+
+function approveCheckout(state, proposal) {
+ const booking = getBooking(state, proposal.bookingId);
+ if (!booking) return;
+ booking.stage = 'checked_out';
+ booking.lifecycle = 'review';
+ booking.checkedOutDay = state.currentDay;
+ booking.turnover.status = 'needed';
+ addThreadMessage(state, booking, 'system', 'Guest checked out. Turnover coordination can begin.');
+ logActivity(state, 'check_out', `Guest checked out of booking ${booking.id}.`, { bookingId: booking.id });
+}
+
+function approveReview(state, proposal) {
+ const booking = getBooking(state, proposal.bookingId);
+ if (!booking) return;
+ booking.review.guestSubmitted = true;
+ booking.review.guestReview = {
+ id: nextId(state, 'review'),
+ rating: 5,
+ summary: 'Smooth recovery and clear communication after an issue.',
+ day: state.currentDay,
+ };
+ booking.stage = 'completed';
+ booking.lifecycle = 'completed';
+ const tripRequest = getTripRequest(state, booking.tripRequestId);
+ if (tripRequest) tripRequest.status = 'completed';
+ addThreadMessage(state, booking, 'guestAgent', 'Guest review posted and profile preferences updated from outcome.');
+ logActivity(state, 'review_submitted', `Guest submitted a review for booking ${booking.id}.`, { bookingId: booking.id });
+}
+
+function approveTurnover(state, proposal) {
+ const booking = getBooking(state, proposal.bookingId);
+ if (!booking) return;
+ booking.turnover.status = 'scheduled';
+ booking.turnover.scheduledDay = state.currentDay;
+ logActivity(state, 'turnover_scheduled', `Turnover scheduled for booking ${booking.id}.`, { bookingId: booking.id });
+}
+
+function approvePriceAdjustment(state, proposal) {
+ const listing = getListing(state, proposal.action.listingId);
+ if (!listing) return;
+ listing.nightlyRate = proposal.action.newNightlyRate;
+ listing.lastPriceUpdateDay = state.currentDay;
+ logActivity(state, 'price_updated', `Host approved pricing update for ${listing.title}.`, { listingId: listing.id });
+}
+
+function rejectIssueResolution(state, proposal) {
+ const booking = getBooking(state, proposal.bookingId);
+ if (!booking || !booking.issue) return;
+ booking.issue.lastHostProposalRejected = true;
+ booking.issue.status = 'open';
+ addThreadMessage(state, booking, 'system', 'Host rejected the proposed resolution; the agent will revise next steps.');
+ logActivity(state, 'proposal_rejected', `Host rejected issue resolution proposal for booking ${booking.id}.`, { proposalId: proposal.id, bookingId: booking.id });
+}
+
+function genericReject(state, proposal) {
+ logActivity(state, 'proposal_rejected', `${proposal.humanName} rejected: ${proposal.title}`, { proposalId: proposal.id, bookingId: proposal.bookingId || null });
+}
+
+export function createInitialState() {
+ const baseDate = '2026-05-04T00:00:00.000Z';
+ return {
+ version: 1,
+ createdAt: Date.now(),
+ baseDate,
+ currentDay: 1,
+ counters: { proposal: 1, booking: 1, message: 1, listing: 100, dispute: 1, adjustment: 1, review: 1, trip: 100 },
+ guests: [
+ {
+ id: 'guest-1',
+ name: 'Maya Chen',
+ notes: 'Returning guest who values fast wifi, clear check-in, and flexible problem handling.',
+ preferences: { prefersQuiet: true, defaultBudget: 320 },
+ tripRequests: [
+ {
+ id: 'trip-1',
+ guestId: 'guest-1',
+ label: 'Austin design sprint',
+ city: 'Austin',
+ startDay: 2,
+ endDay: 5,
+ budget: 265,
+ partySize: 1,
+ vibe: 'work',
+ mustHave: ['fast wifi', 'self check-in'],
+ checkInPreference: '9:00 PM',
+ specialRequest: 'Quiet desk setup for late product review.',
+ status: 'planning',
+ },
+ {
+ id: 'trip-2',
+ guestId: 'guest-1',
+ label: 'Malibu family reset',
+ city: 'Malibu',
+ startDay: 4,
+ endDay: 7,
+ budget: 410,
+ partySize: 2,
+ vibe: 'coastal',
+ mustHave: ['parking', 'ocean view'],
+ checkInPreference: '4:30 PM',
+ specialRequest: 'Need child-friendly arrival instructions.',
+ status: 'planning',
+ },
+ ],
+ },
+ ],
+ hosts: [
+ { id: 'host-1', name: 'Elena Ruiz', propertyIds: ['listing-1', 'listing-2'] },
+ { id: 'host-2', name: 'Marcus Lee', propertyIds: ['listing-3'] },
+ ],
+ admins: [{ id: 'admin-1', name: 'Jordan Kim' }],
+ rules: {
+ disputeCreditCap: 180,
+ guestApprovalRequired: true,
+ hostApprovalRequired: true,
+ adminApprovalRequired: true,
+ },
+ listings: [
+ {
+ id: 'listing-1',
+ hostId: 'host-1',
+ title: 'Harbor Loft',
+ city: 'Austin',
+ neighborhood: 'East Austin',
+ nightlyRate: 275,
+ cleaningFee: 45,
+ maxGuests: 2,
+ tags: ['work', 'design'],
+ features: ['fast wifi', 'self check-in', 'desk', 'coffee'],
+ active: true,
+ availabilityStrategy: 'normal',
+ occupancyTarget: 0.7,
+ },
+ {
+ id: 'listing-2',
+ hostId: 'host-1',
+ title: 'Ocean View Bungalow',
+ city: 'Malibu',
+ neighborhood: 'Point Dume',
+ nightlyRate: 395,
+ cleaningFee: 65,
+ maxGuests: 4,
+ tags: ['coastal', 'family'],
+ features: ['parking', 'ocean view', 'smart lock', 'washer'],
+ active: true,
+ availabilityStrategy: 'normal',
+ occupancyTarget: 0.8,
+ },
+ {
+ id: 'listing-3',
+ hostId: 'host-2',
+ title: 'Garden Studio',
+ city: 'Austin',
+ neighborhood: 'South Congress',
+ nightlyRate: 225,
+ cleaningFee: 35,
+ maxGuests: 2,
+ tags: ['work', 'budget'],
+ features: ['fast wifi', 'parking', 'patio'],
+ active: true,
+ availabilityStrategy: 'normal',
+ occupancyTarget: 0.6,
+ },
+ ],
+ bookings: [],
+ proposals: [],
+ activityLog: [
+ {
+ id: 'seed-1',
+ day: 1,
+ type: 'system',
+ message: 'Seeded demo state loaded with returning guest, multi-property host, and marketplace admin.',
+ at: Date.now(),
+ },
+ ],
+ };
+}
+
+export function addTripRequest(state, input) {
+ const guest = getGuest(state, input.guestId || state.guests[0].id);
+ const trip = {
+ id: nextId(state, 'trip'),
+ guestId: guest.id,
+ label: input.label,
+ city: input.city,
+ startDay: Number(input.startDay),
+ endDay: Number(input.endDay),
+ budget: Number(input.budget),
+ partySize: Number(input.partySize || 1),
+ vibe: input.vibe || 'work',
+ mustHave: (input.mustHave || []).filter(Boolean),
+ checkInPreference: input.checkInPreference || '5:00 PM',
+ specialRequest: input.specialRequest || 'Standard arrival details requested.',
+ status: 'planning',
+ };
+ guest.tripRequests.push(trip);
+ logActivity(state, 'trip_request_added', `New guest trip request added for ${trip.city}.`, { tripRequestId: trip.id });
+ return trip;
+}
+
+export function addListing(state, input) {
+ const host = getHost(state, input.hostId || state.hosts[0].id);
+ const listing = {
+ id: nextId(state, 'listing'),
+ hostId: host.id,
+ title: input.title,
+ city: input.city,
+ neighborhood: input.neighborhood || 'Custom',
+ nightlyRate: Number(input.nightlyRate),
+ cleaningFee: Number(input.cleaningFee || 40),
+ maxGuests: Number(input.maxGuests || 2),
+ tags: (input.tags || []).filter(Boolean),
+ features: (input.features || []).filter(Boolean),
+ active: true,
+ availabilityStrategy: input.availabilityStrategy || 'normal',
+ occupancyTarget: Number(input.occupancyTarget || 0.7),
+ };
+ state.listings.push(listing);
+ host.propertyIds.push(listing.id);
+ logActivity(state, 'listing_added', `Host added listing ${listing.title}.`, { listingId: listing.id });
+ return listing;
+}
+
+export function searchListings(state, criteria = {}) {
+ return state.listings
+ .filter((listing) => listing.active !== false)
+ .filter((listing) => !criteria.city || listing.city === criteria.city)
+ .filter((listing) => !criteria.maxNightlyRate || listing.nightlyRate <= Number(criteria.maxNightlyRate))
+ .filter((listing) => !criteria.partySize || listing.maxGuests >= Number(criteria.partySize))
+ .map((listing) => ({ ...listing }));
+}
+
+export function advanceDay(state, days = 1) {
+ state.currentDay += Number(days);
+ for (const booking of state.bookings) {
+ if (booking.stage === 'booked' && state.currentDay > booking.startDay) {
+ booking.lifecycle = 'arrival_window';
+ }
+ }
+ logActivity(state, 'time_advanced', `Simulation advanced to day ${state.currentDay} (${addDays(state.baseDate, state.currentDay - 1)}).`, { currentDay: state.currentDay });
+}
+
+export function reportStayIssue(state, { bookingId, summary, severity = 'medium' }) {
+ const booking = getBooking(state, bookingId);
+ if (!booking) return;
+ booking.issue = {
+ status: 'open',
+ summary,
+ severity,
+ reportedDay: state.currentDay,
+ resolution: null,
+ lastHostProposalRejected: false,
+ };
+ addThreadMessage(state, booking, 'guestAgent', `Issue detected during stay: ${summary}. Severity: ${severity}.`);
+ logActivity(state, 'issue_reported', `Issue reported for booking ${booking.id}.`, { bookingId: booking.id });
+}
+
+export function runGuestAgentLoop(state) {
+ for (const guest of state.guests) {
+ for (const tripRequest of guest.tripRequests) {
+ if (tripRequest.status === 'planning' && !bookingForTrip(state, tripRequest.id)) {
+ const bestListing = matchTripToListing(state, tripRequest);
+ if (bestListing && !proposalExists(state, (proposal) => proposal.action.tripRequestId === tripRequest.id && proposal.action.type === 'create_inquiry')) {
+ createProposal(state, {
+ humanRole: 'guest',
+ humanId: guest.id,
+ humanName: guest.name,
+ agentName: 'Guest Agent',
+ title: `Book ${bestListing.title} for ${tripRequest.label}`,
+ description: `Best match found in ${tripRequest.city}: ${bestListing.title} at $${bestListing.nightlyRate}/night with ${tripRequest.mustHave.join(', ')}.`,
+ action: {
+ type: 'create_inquiry',
+ tripRequestId: tripRequest.id,
+ listingId: bestListing.id,
+ },
+ loopTrace: buildLoopTrace({
+ actor: 'Guest Agent',
+ monitor: 'Watched open trip requests, preferences, and listing inventory.',
+ detect: `Detected an unbooked trip request for ${tripRequest.label}.`,
+ propose: `Proposed sending a ready-to-go inquiry to ${bestListing.title}.`,
+ }),
+ });
+ }
+ }
+ }
+ }
+
+ for (const booking of state.bookings) {
+ const guest = getGuest(state, booking.guestId);
+ if (booking.stage === 'negotiating' && booking.pendingCounter && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'accept_counter')) {
+ createProposal(state, {
+ humanRole: 'guest',
+ humanId: guest.id,
+ humanName: guest.name,
+ agentName: 'Guest Agent',
+ bookingId: booking.id,
+ title: `Accept host counter for ${getListing(state, booking.listingId).title}`,
+ description: `Host can honor the request with ${booking.pendingCounter.checkInTime} check-in at $${booking.pendingCounter.nightlyRate}/night.`,
+ action: { type: 'accept_counter' },
+ loopTrace: buildLoopTrace({
+ actor: 'Guest Agent',
+ monitor: 'Watched booking negotiations and host replies.',
+ detect: 'Detected a new host counter-offer requiring guest approval.',
+ propose: 'Proposed accepting the revised terms and confirming the stay.',
+ }),
+ });
+ }
+
+ if (booking.stage === 'booked' && state.currentDay >= booking.startDay && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'check_in_guest')) {
+ createProposal(state, {
+ humanRole: 'guest',
+ humanId: guest.id,
+ humanName: guest.name,
+ agentName: 'Guest Agent',
+ bookingId: booking.id,
+ title: `Check in to ${getListing(state, booking.listingId).title}`,
+ description: `Arrival window is open. Check in using the approved ${booking.confirmedCheckIn || booking.requestedCheckIn} plan.`,
+ action: { type: 'check_in_guest' },
+ loopTrace: buildLoopTrace({
+ actor: 'Guest Agent',
+ monitor: 'Watched confirmed bookings and arrival windows.',
+ detect: 'Detected that check-in is due today.',
+ propose: 'Proposed executing the confirmed check-in plan.',
+ }),
+ });
+ }
+
+ if (booking.issue?.status === 'open' && booking.issue.lastHostProposalRejected && booking.dispute.status === 'none' && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'escalate_dispute')) {
+ createProposal(state, {
+ humanRole: 'guest',
+ humanId: guest.id,
+ humanName: guest.name,
+ agentName: 'Guest Agent',
+ bookingId: booking.id,
+ title: `Escalate ${getListing(state, booking.listingId).title} issue to admin`,
+ description: `The host did not approve the proposed recovery for “${booking.issue.summary}”. Escalate to admin mediation?`,
+ action: { type: 'escalate_dispute' },
+ loopTrace: buildLoopTrace({
+ actor: 'Guest Agent',
+ monitor: 'Watched in-stay issues, host decisions, and dispute thresholds.',
+ detect: 'Detected an unresolved issue after a failed recovery path.',
+ propose: 'Proposed escalating to marketplace admin for mediation.',
+ }),
+ });
+ }
+
+ if (booking.stage === 'checked_in' && state.currentDay >= booking.endDay && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'check_out_guest')) {
+ createProposal(state, {
+ humanRole: 'guest',
+ humanId: guest.id,
+ humanName: guest.name,
+ agentName: 'Guest Agent',
+ bookingId: booking.id,
+ title: `Check out of ${getListing(state, booking.listingId).title}`,
+ description: 'Stay has reached checkout day. Complete checkout and trigger final settlement.',
+ action: { type: 'check_out_guest' },
+ loopTrace: buildLoopTrace({
+ actor: 'Guest Agent',
+ monitor: 'Watched active stays and checkout windows.',
+ detect: 'Detected the stay has reached departure day.',
+ propose: 'Proposed checking out and starting the post-stay flow.',
+ }),
+ });
+ }
+
+ if (booking.stage === 'checked_out' && !booking.review.guestSubmitted && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'submit_review')) {
+ createProposal(state, {
+ humanRole: 'guest',
+ humanId: guest.id,
+ humanName: guest.name,
+ agentName: 'Guest Agent',
+ bookingId: booking.id,
+ title: `Submit review for ${getListing(state, booking.listingId).title}`,
+ description: 'Review window is open. Post a review and update future travel preferences from the outcome.',
+ action: { type: 'submit_review' },
+ loopTrace: buildLoopTrace({
+ actor: 'Guest Agent',
+ monitor: 'Watched completed stays, refunds, and review windows.',
+ detect: 'Detected a post-stay review opportunity.',
+ propose: 'Proposed submitting a review and learning from the result.',
+ }),
+ });
+ }
+ }
+}
+
+export function runHostAgentLoop(state) {
+ for (const booking of state.bookings) {
+ const host = getHost(state, booking.hostId);
+ const listing = getListing(state, booking.listingId);
+ if (booking.stage === 'inquiry' && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'counter_inquiry')) {
+ const counterNightlyRate = Math.max(booking.proposedNightlyRate, listing.nightlyRate - 10);
+ createProposal(state, {
+ humanRole: 'host',
+ humanId: host.id,
+ humanName: host.name,
+ agentName: 'Host Agent',
+ bookingId: booking.id,
+ title: `Respond to inquiry for ${listing.title}`,
+ description: `Suggest ${counterNightlyRate}/night and ${booking.requestedCheckIn} self check-in with tailored arrival instructions.`,
+ action: {
+ type: 'counter_inquiry',
+ counterNightlyRate,
+ checkInTime: booking.requestedCheckIn,
+ note: 'Arrival request works with a slightly adjusted rate and digital guide.',
+ },
+ loopTrace: buildLoopTrace({
+ actor: 'Host Agent',
+ monitor: 'Watched new inquiries, pricing, and special requests.',
+ detect: 'Detected a guest inquiry requiring a host response.',
+ propose: 'Proposed a ready-to-send counter-offer and arrival plan.',
+ }),
+ });
+ }
+
+ if (booking.stage === 'checked_in' && booking.issue?.status === 'open' && booking.dispute.status === 'none' && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'offer_issue_resolution')) {
+ const creditAmount = booking.issue.severity === 'high' ? 90 : 45;
+ createProposal(state, {
+ humanRole: 'host',
+ humanId: host.id,
+ humanName: host.name,
+ agentName: 'Host Agent',
+ bookingId: booking.id,
+ title: `Resolve stay issue at ${listing.title}`,
+ description: `Offer a ${creditAmount} credit and rapid support for “${booking.issue.summary}”.`,
+ action: {
+ type: 'offer_issue_resolution',
+ creditAmount,
+ resolution: `Applied a $${creditAmount} goodwill credit and escalated vendor support.`,
+ },
+ loopTrace: buildLoopTrace({
+ actor: 'Host Agent',
+ monitor: 'Watched active stays, guest reports, and service alerts.',
+ detect: 'Detected an in-stay issue affecting the guest experience.',
+ propose: 'Proposed a concrete service recovery package for host approval.',
+ }),
+ });
+ }
+
+ if (booking.stage === 'checked_out' && booking.turnover.status === 'needed' && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'schedule_turnover')) {
+ createProposal(state, {
+ humanRole: 'host',
+ humanId: host.id,
+ humanName: host.name,
+ agentName: 'Host Agent',
+ bookingId: booking.id,
+ title: `Schedule turnover for ${listing.title}`,
+ description: 'Coordinate cleaning and restocking before the next arrival.',
+ action: { type: 'schedule_turnover' },
+ loopTrace: buildLoopTrace({
+ actor: 'Host Agent',
+ monitor: 'Watched checkout completions and back-to-back availability.',
+ detect: 'Detected turnover work needed between bookings.',
+ propose: 'Proposed scheduling cleaners and restocking tasks.',
+ }),
+ });
+ }
+ }
+
+ for (const listing of state.listings) {
+ const host = getHost(state, listing.hostId);
+ const shouldAdjust = listing.availabilityStrategy === 'underbooked' || state.currentDay % 3 === 0;
+ if (shouldAdjust && !proposalExists(state, (proposal) => proposal.action.type === 'adjust_price' && proposal.action.listingId === listing.id)) {
+ const newNightlyRate = Math.max(120, Math.round(listing.nightlyRate * 0.92));
+ createProposal(state, {
+ humanRole: 'host',
+ humanId: host.id,
+ humanName: host.name,
+ agentName: 'Host Agent',
+ title: `Adjust nightly price for ${listing.title}`,
+ description: `Occupancy looks soft. Lower the nightly rate from $${listing.nightlyRate} to $${newNightlyRate}?`,
+ action: { type: 'adjust_price', listingId: listing.id, newNightlyRate },
+ loopTrace: buildLoopTrace({
+ actor: 'Host Agent',
+ monitor: 'Watched listing occupancy, pace, and rate competitiveness.',
+ detect: 'Detected underbooking pressure on upcoming dates.',
+ propose: 'Proposed a dynamic pricing adjustment for host approval.',
+ }),
+ });
+ }
+ }
+}
+
+export function runAdminAgentLoop(state) {
+ for (const booking of state.bookings) {
+ const admin = state.admins[0];
+ if (booking.dispute?.status === 'open' && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.humanRole === 'admin')) {
+ const creditAmount = Math.min(state.rules.disputeCreditCap, booking.issue?.severity === 'high' ? 120 : 60);
+ createProposal(state, {
+ humanRole: 'admin',
+ humanId: admin.id,
+ humanName: admin.name,
+ agentName: 'Admin Agent',
+ bookingId: booking.id,
+ title: `Mediate dispute for ${getListing(state, booking.listingId).title}`,
+ description: `Issue summary: ${booking.issue?.summary}. Apply a neutral mediation credit of $${creditAmount}?`,
+ action: {
+ type: 'admin_resolution',
+ creditAmount,
+ resolution: `Admin issued a $${creditAmount} marketplace credit after reviewing the unresolved stay issue.`,
+ },
+ loopTrace: buildLoopTrace({
+ actor: 'Admin Agent',
+ monitor: 'Watched open disputes, failed negotiations, and marketplace rules.',
+ detect: 'Detected a dispute that guest and host agents could not close.',
+ propose: 'Proposed a rule-based mediation outcome for admin approval.',
+ }),
+ });
+ }
+ }
+}
+
+export function runAllAgentLoops(state) {
+ runGuestAgentLoop(state);
+ runHostAgentLoop(state);
+ runAdminAgentLoop(state);
+ return state;
+}
+
+export function approveProposal(state, proposalId) {
+ const proposal = state.proposals.find((candidate) => candidate.id === proposalId);
+ if (!proposal || proposal.status !== 'pending') return state;
+ proposal.status = 'approved';
+ proposal.decidedDay = state.currentDay;
+
+ switch (proposal.action.type) {
+ case 'create_inquiry':
+ approveGuestBookingProposal(state, proposal);
+ break;
+ case 'counter_inquiry':
+ approveHostCounterProposal(state, proposal);
+ break;
+ case 'accept_counter':
+ approveGuestCounterAcceptance(state, proposal);
+ break;
+ case 'check_in_guest':
+ approveCheckIn(state, proposal);
+ break;
+ case 'offer_issue_resolution':
+ approveIssueResolution(state, proposal);
+ break;
+ case 'escalate_dispute':
+ approveDisputeEscalation(state, proposal);
+ break;
+ case 'admin_resolution':
+ approveAdminResolution(state, proposal);
+ break;
+ case 'check_out_guest':
+ approveCheckout(state, proposal);
+ break;
+ case 'submit_review':
+ approveReview(state, proposal);
+ break;
+ case 'schedule_turnover':
+ approveTurnover(state, proposal);
+ break;
+ case 'adjust_price':
+ approvePriceAdjustment(state, proposal);
+ break;
+ default:
+ logActivity(state, 'proposal_approved', `Approved proposal ${proposal.title}.`, { proposalId });
+ break;
+ }
+
+ return state;
+}
+
+export function rejectProposal(state, proposalId) {
+ const proposal = state.proposals.find((candidate) => candidate.id === proposalId);
+ if (!proposal || proposal.status !== 'pending') return state;
+ proposal.status = 'rejected';
+ proposal.decidedDay = state.currentDay;
+
+ switch (proposal.action.type) {
+ case 'offer_issue_resolution':
+ rejectIssueResolution(state, proposal);
+ break;
+ default:
+ genericReject(state, proposal);
+ break;
+ }
+
+ return state;
+}
+
+export function serializeState(state) {
+ return JSON.stringify(state);
+}
+
+export function hydrateState(serialized) {
+ return typeof serialized === 'string' ? JSON.parse(serialized) : deepClone(serialized);
+}
+
+export function getDayLabel(state, day = state.currentDay) {
+ return addDays(state.baseDate, day - 1);
+}
+
+export function getProposalQueue(state, humanRole) {
+ return state.proposals.filter((proposal) => proposal.status === 'pending' && (!humanRole || proposal.humanRole === humanRole));
+}
+
+export function getVisibleThread(state, bookingId) {
+ const booking = getBooking(state, bookingId);
+ return booking?.thread || [];
+}
+
+export function summarizeBooking(state, booking) {
+ const listing = getListing(state, booking.listingId);
+ const guest = getGuest(state, booking.guestId);
+ return {
+ ...booking,
+ listingTitle: listing?.title,
+ guestName: guest?.name,
+ hostName: getHost(state, booking.hostId)?.name,
+ totalValue: nightlyTotal(booking),
+ };
+}
diff --git a/deliverable/stayflow_agents/deliverable/styles.css b/deliverable/stayflow_agents/deliverable/styles.css
new file mode 100644
index 000000000..f9125f44c
--- /dev/null
+++ b/deliverable/stayflow_agents/deliverable/styles.css
@@ -0,0 +1,364 @@
+:root {
+ --bg: #07111f;
+ --bg-soft: #0e1c31;
+ --panel: rgba(16, 29, 49, 0.92);
+ --panel-2: rgba(20, 36, 61, 0.96);
+ --line: rgba(154, 180, 214, 0.18);
+ --text: #eef4ff;
+ --muted: #9bb0d2;
+ --accent: #6ae3ff;
+ --accent-2: #7af0b4;
+ --warn: #ffbf69;
+ --danger: #ff7b9c;
+ --guest: #6ae3ff;
+ --host: #7af0b4;
+ --admin: #ffbf69;
+ --shadow: 0 22px 50px rgba(0, 0, 0, 0.28);
+ --radius: 18px;
+}
+
+* { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; min-height: 100%; }
+body {
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ color: var(--text);
+ background:
+ radial-gradient(circle at top left, rgba(106, 227, 255, 0.16), transparent 26%),
+ radial-gradient(circle at top right, rgba(122, 240, 180, 0.12), transparent 20%),
+ linear-gradient(180deg, #040b15 0%, #081322 36%, #091522 100%);
+}
+
+a { color: inherit; }
+button, input, select, textarea {
+ font: inherit;
+}
+
+.shell {
+ width: min(1440px, calc(100% - 32px));
+ margin: 0 auto;
+ padding: 28px 0 64px;
+}
+
+.hero {
+ display: grid;
+ grid-template-columns: 1.35fr .9fr;
+ gap: 20px;
+ margin-bottom: 18px;
+}
+
+.hero-card,
+.panel,
+.queue-column {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ box-shadow: var(--shadow);
+ border-radius: var(--radius);
+ backdrop-filter: blur(14px);
+}
+
+.hero-card {
+ padding: 26px;
+}
+
+.hero h1 {
+ margin: 0 0 10px;
+ font-size: clamp(2rem, 4vw, 3.2rem);
+ line-height: 1.05;
+}
+
+.hero p {
+ margin: 0;
+ color: var(--muted);
+ max-width: 65ch;
+}
+
+.hero-badges,
+.stage-strip,
+.metric-row,
+.listing-meta,
+.inline-actions,
+.badge-row,
+.thread-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.badge,
+.metric-card span,
+.stage-pill,
+.tag,
+.status-pill,
+.thread-tag {
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ padding: 7px 12px;
+ font-size: 0.86rem;
+ color: var(--muted);
+ background: rgba(255,255,255,0.03);
+}
+
+.badge strong,
+.metric-card strong { color: var(--text); }
+
+.stage-pill {
+ color: var(--text);
+ background: rgba(106, 227, 255, 0.08);
+}
+
+.hero-side {
+ display: grid;
+ gap: 14px;
+}
+
+.metric-row {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 14px;
+}
+
+.metric-card {
+ padding: 18px;
+ background: var(--panel-2);
+ border: 1px solid var(--line);
+ border-radius: 16px;
+}
+
+.metric-card h3 {
+ margin: 10px 0 4px;
+ font-size: 1.85rem;
+}
+
+.controls {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 20px;
+}
+
+.control-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+button {
+ border: 0;
+ border-radius: 12px;
+ padding: 11px 15px;
+ cursor: pointer;
+ transition: transform .15s ease, opacity .15s ease, background .15s ease;
+}
+button:hover { transform: translateY(-1px); }
+button:active { transform: translateY(0); }
+button.primary { background: linear-gradient(135deg, #63dfff, #45c8ff); color: #032033; font-weight: 700; }
+button.secondary { background: rgba(255,255,255,0.08); color: var(--text); border: 1px solid var(--line); }
+button.approve { background: rgba(122, 240, 180, 0.18); color: #caffea; border: 1px solid rgba(122, 240, 180, 0.35); }
+button.reject { background: rgba(255, 123, 156, 0.14); color: #ffdbe6; border: 1px solid rgba(255, 123, 156, 0.3); }
+button.warn { background: rgba(255, 191, 105, 0.17); color: #ffedcb; border: 1px solid rgba(255, 191, 105, 0.33); }
+button.ghost { background: transparent; color: var(--muted); border: 1px dashed var(--line); }
+button.small { padding: 8px 12px; font-size: .88rem; }
+button.full { width: 100%; }
+
+.queue-grid,
+.grid {
+ display: grid;
+ gap: 18px;
+}
+
+.queue-grid {
+ grid-template-columns: repeat(3, 1fr);
+ margin-bottom: 18px;
+}
+
+.queue-column { padding: 18px; }
+.queue-column header { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
+.queue-column h2,
+.panel h2 { margin: 0; font-size: 1.1rem; }
+
+.queue-column.guest { border-top: 3px solid var(--guest); }
+.queue-column.host { border-top: 3px solid var(--host); }
+.queue-column.admin { border-top: 3px solid var(--admin); }
+
+.proposal-card,
+.booking-card,
+.listing-card,
+.trip-card,
+.activity-item,
+.dispute-card {
+ padding: 16px;
+ background: rgba(255,255,255,0.03);
+ border: 1px solid var(--line);
+ border-radius: 16px;
+}
+
+.proposal-card + .proposal-card,
+.booking-card + .booking-card,
+.listing-card + .listing-card,
+.trip-card + .trip-card,
+.activity-item + .activity-item,
+.dispute-card + .dispute-card {
+ margin-top: 12px;
+}
+
+.proposal-card h3,
+.booking-card h3,
+.listing-card h3,
+.trip-card h3,
+.dispute-card h3 {
+ margin: 0 0 6px;
+ font-size: 1rem;
+}
+
+.proposal-meta,
+.muted,
+.micro,
+.helper {
+ color: var(--muted);
+}
+
+.micro { font-size: .83rem; }
+.helper { font-size: .9rem; line-height: 1.5; }
+
+.grid {
+ grid-template-columns: 1.05fr 1fr;
+ align-items: start;
+}
+
+.panel {
+ padding: 20px;
+}
+
+.panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+}
+
+.form-grid.compact {
+ grid-template-columns: repeat(3, 1fr);
+}
+
+label {
+ display: grid;
+ gap: 6px;
+ font-size: .9rem;
+ color: var(--muted);
+}
+
+input,
+select,
+textarea {
+ width: 100%;
+ border-radius: 12px;
+ border: 1px solid rgba(154, 180, 214, 0.22);
+ background: rgba(5, 11, 21, 0.65);
+ color: var(--text);
+ padding: 11px 12px;
+}
+
+textarea { min-height: 90px; resize: vertical; }
+
+hr.sep {
+ border: 0;
+ border-top: 1px solid var(--line);
+ margin: 18px 0;
+}
+
+.listing-card .price {
+ font-size: 1.35rem;
+ margin: 8px 0;
+}
+
+.status-pill[data-status="booked"],
+.status-pill[data-status="checked_in"],
+.status-pill[data-status="completed"],
+.status-pill[data-status="resolved"] {
+ color: #d9fff0;
+ background: rgba(122, 240, 180, 0.12);
+}
+
+.status-pill[data-status="inquiry"],
+.status-pill[data-status="negotiating"],
+.status-pill[data-status="open"],
+.status-pill[data-status="needed"] {
+ color: #ffeac9;
+ background: rgba(255, 191, 105, 0.12);
+}
+
+.status-pill[data-status="escalated"],
+.status-pill[data-status="closed"],
+.status-pill[data-status="rejected"] {
+ color: #ffdbe6;
+ background: rgba(255, 123, 156, 0.12);
+}
+
+.thread {
+ display: grid;
+ gap: 10px;
+ margin-top: 12px;
+}
+
+.thread-message {
+ padding: 12px 14px;
+ border-left: 3px solid rgba(106, 227, 255, 0.35);
+ background: rgba(255,255,255,0.03);
+ border-radius: 12px;
+}
+
+.thread-message.hostAgent { border-left-color: rgba(122, 240, 180, 0.55); }
+.thread-message.admin { border-left-color: rgba(255, 191, 105, 0.55); }
+.thread-message.system { border-left-color: rgba(154, 180, 214, 0.4); }
+
+.two-col {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+}
+
+.alert {
+ padding: 14px 16px;
+ border-radius: 14px;
+ border: 1px solid rgba(255, 191, 105, 0.32);
+ background: rgba(255, 191, 105, 0.08);
+ color: #ffe7c2;
+}
+
+.empty {
+ padding: 16px;
+ border: 1px dashed var(--line);
+ border-radius: 14px;
+ color: var(--muted);
+}
+
+footer.note {
+ margin-top: 18px;
+ color: var(--muted);
+ text-align: center;
+ font-size: .9rem;
+}
+
+@media (max-width: 1100px) {
+ .hero,
+ .queue-grid,
+ .grid { grid-template-columns: 1fr; }
+ .metric-row { grid-template-columns: repeat(4, 1fr); }
+}
+
+@media (max-width: 760px) {
+ .shell { width: min(100% - 20px, 1440px); }
+ .metric-row,
+ .form-grid,
+ .form-grid.compact,
+ .two-col { grid-template-columns: 1fr; }
+}
diff --git a/deliverable/stayflow_agents/tasks/changedoc.md b/deliverable/stayflow_agents/tasks/changedoc.md
new file mode 100644
index 000000000..ab6739055
--- /dev/null
+++ b/deliverable/stayflow_agents/tasks/changedoc.md
@@ -0,0 +1,94 @@
+# Change Document
+
+**Based on:** original — final consolidated deliverable
+
+## Summary
+Delivered a self-contained prototype of a short-stay rental marketplace with dedicated Guest Agent, Host Agent, and Admin Agent workflows. The app demonstrates the full inquiry → negotiation → booking → check-in → stay monitoring → dispute escalation → check-out → review lifecycle, requires explicit one-tap human approval before every action, preserves state across sessions with browser persistence, supports multiple guest stays and multiple host properties, and keeps all agent-to-agent exchanges visible in plain language.
+
+## Decisions
+
+### DEC-001: Build a self-contained browser prototype
+**Origin:** agent1.1 — NEW
+**Choice:** Implement the product as a static HTML/CSS/JavaScript app with local persistence instead of a separate backend service.
+**Why:** A static browser prototype is the fastest way to demonstrate the full marketplace lifecycle, approval UX, and repeated sessions while remaining easy to run locally and verify.
+**Alternatives considered:**
+- Full backend/API stack: rejected because it adds infrastructure overhead without improving the prototype goals.
+- CLI simulation: rejected because approval taps, proposal queues, and visible negotiation are clearer in a UI.
+**Implementation:**
+- `deliverable/index.html` — application shell
+- `deliverable/styles.css` — interface styling for proposal queues, marketplace panels, and lifecycle cards
+- `deliverable/app.mjs` — browser bootstrap, persistence wiring, rendering, event handling, auto-monitor loop
+- `deliverable/README.md` — local run and demo instructions
+
+### DEC-002: Make the six-stage agent loop explicit in every proposal
+**Origin:** agent1.1 — NEW
+**Choice:** Every agent action is represented as a proposal card backed by the same monitor → detect → propose → approve → act → confirm loop.
+**Why:** The task centers on agent behavior with human approval. Making the loop visible proves that no action executes until a human explicitly approves it.
+**Alternatives considered:**
+- Hidden background automation with generic notifications: rejected because it obscures how the agents operate and weakens the demo.
+**Implementation:**
+- `deliverable/state-engine.mjs` — `buildLoopTrace()`, `runGuestAgentLoop()`, `runHostAgentLoop()`, `runAdminAgentLoop()`
+- `deliverable/app.mjs` — `renderProposal()`, grouped approval queues, approve/reject button wiring
+
+### DEC-003: Separate pure state transitions from the DOM layer
+**Origin:** agent1.1 — NEW
+**Choice:** Keep marketplace rules, booking transitions, approvals, disputes, pricing, turnovers, and serialization in a standalone state engine imported by the UI.
+**Why:** Separating state logic from rendering keeps the implementation easier to test, reason about, and extend.
+**Alternatives considered:**
+- Single monolithic browser script: rejected because it would make lifecycle logic harder to verify and reuse.
+**Implementation:**
+- `deliverable/state-engine.mjs` — core state creation, agent loops, approval/rejection handlers, persistence helpers
+- `deliverable/app.mjs` — UI-only orchestration and browser interaction layer
+- `tests/prototype.test.mjs` — state-engine lifecycle coverage
+- `tests/ui-smoke.test.mjs` — browser-module smoke coverage with DOM stubs
+
+### DEC-004: Seed the prototype with realistic multi-role, multi-property data
+**Origin:** agent1.1 — NEW
+**Choice:** Start the prototype with a returning guest, multiple trip requests, multiple hosts, multiple listings, and admin rules, while still allowing live creation of new trip requests and listings.
+**Why:** Seeded data makes the marketplace immediately demoable and proves multi-stay and multi-property support without setup friction.
+**Alternatives considered:**
+- Empty initial state: rejected because it slows down evaluation of the full lifecycle.
+**Implementation:**
+- `deliverable/state-engine.mjs` — `createInitialState()`, `addTripRequest()`, `addListing()`
+- `deliverable/app.mjs` — guest trip planner form and host listing studio form
+
+### DEC-005: Use a demo-day simulation to drive lifecycle events deterministically
+**Origin:** agent1.1 — NEW
+**Choice:** Represent time as numbered demo days and trigger arrival, stay, checkout, turnover, pricing, and review proposals from stateful day advancement.
+**Why:** Deterministic demo time makes the full lifecycle easy to reproduce quickly without waiting on real clocks or background jobs.
+**Alternatives considered:**
+- Real-time waiting: rejected because it is slower and less reliable for demos and tests.
+**Implementation:**
+- `deliverable/state-engine.mjs` — `advanceDay()`, date labeling, day-based lifecycle detection
+- `deliverable/app.mjs` — advance-day controls and fast-forward lifecycle helper
+
+### DEC-006: Keep agent-to-agent exchanges attached to each booking in plain language
+**Origin:** agent1.1 — NEW
+**Choice:** Store negotiation, service recovery, escalation, and admin mediation messages directly on the booking thread and render them inline.
+**Why:** The requirement explicitly says all agent-to-agent exchanges must remain visible to both humans in plain language.
+**Alternatives considered:**
+- Detached inbox or hidden logs: rejected because it breaks lifecycle context and makes approvals less legible.
+**Implementation:**
+- `deliverable/state-engine.mjs` — `addThreadMessage()`, booking creation, negotiation handlers, dispute handlers
+- `deliverable/app.mjs` — `renderBooking()` thread rendering
+
+### DEC-007: Cover the critical lifecycle with automated verification
+**Origin:** agent1.1 — NEW
+**Choice:** Verify the prototype with automated Node tests for negotiation, escalation, persistence, multi-stay support, dynamic pricing, and UI smoke behavior.
+**Why:** The prototype is stateful and approval-driven, so automated checks provide strong evidence that the end-to-end flows work as intended.
+**Alternatives considered:**
+- Manual-only verification: rejected because it is less repeatable and easier to miss lifecycle regressions.
+**Implementation:**
+- `tests/prototype.test.mjs` — booking negotiation, dispute escalation, persistence, dynamic pricing, concurrent booking tests
+- `tests/ui-smoke.test.mjs` — UI render and click-through smoke test
+- `.massgen_scratch/verification/final/node-tests.log` — final local test run output
+- `.massgen_scratch/verification/final/http-smoke.txt` — static server smoke note
+
+## Deliberation Trail
+- agent1.1 introduced the static browser prototype direction and rejected a heavier backend because a self-contained UI better demonstrates approval-driven agent workflows.
+- agent1.1 made the six-stage loop a first-class UI concept rather than a hidden internal process so reviewers can see each proposal’s reasoning and approval boundary.
+- agent1.1 split the solution into a pure workflow/state module and a browser UI layer, which enabled the final deliverable to preserve the same logic in both the demo and the tests.
+- agent1.1 chose seeded multi-role demo data plus editable live state so the final product could immediately show multiple concurrent stays, recurring hosts, and persistent user history.
+- agent1.1 anchored lifecycle progress to numbered demo days, which became the final mechanism for deterministic check-in, stay, checkout, turnover, and review proposals.
+- agent1.1 attached visible booking threads to negotiations and disputes, and the final deliverable keeps that design unchanged because it is the clearest way to satisfy the plain-language transparency requirement.
+- The final consolidation preserves all of those decisions and updates implementation references to this workspace’s delivered files.
diff --git a/deliverable/stayflow_agents/tests/prototype.test.mjs b/deliverable/stayflow_agents/tests/prototype.test.mjs
new file mode 100644
index 000000000..d3b98e11f
--- /dev/null
+++ b/deliverable/stayflow_agents/tests/prototype.test.mjs
@@ -0,0 +1,174 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ createInitialState,
+ runAllAgentLoops,
+ approveProposal,
+ rejectProposal,
+ advanceDay,
+ reportStayIssue,
+ serializeState,
+ hydrateState,
+} from '../deliverable/state-engine.mjs';
+
+test('supports proposal-driven booking negotiation through confirmation', () => {
+ const state = createInitialState();
+
+ runAllAgentLoops(state);
+ const guestProposal = state.proposals.find(
+ (proposal) => proposal.status === 'pending' && proposal.humanRole === 'guest',
+ );
+
+ assert.ok(guestProposal, 'guest agent should propose a booking action');
+ approveProposal(state, guestProposal.id);
+
+ const inquiry = state.bookings.find((booking) => booking.stage === 'inquiry');
+ assert.ok(inquiry, 'approving guest proposal should create an inquiry');
+
+ runAllAgentLoops(state);
+ const hostProposal = state.proposals.find(
+ (proposal) =>
+ proposal.status === 'pending' &&
+ proposal.humanRole === 'host' &&
+ proposal.bookingId === inquiry.id,
+ );
+
+ assert.ok(hostProposal, 'host agent should react to the inquiry');
+ approveProposal(state, hostProposal.id);
+
+ assert.equal(
+ state.bookings.find((booking) => booking.id === inquiry.id)?.stage,
+ 'negotiating',
+ 'host approval should create a counter or negotiated response',
+ );
+
+ runAllAgentLoops(state);
+ const guestNegotiationProposal = state.proposals.find(
+ (proposal) =>
+ proposal.status === 'pending' &&
+ proposal.humanRole === 'guest' &&
+ proposal.bookingId === inquiry.id &&
+ proposal.action.type === 'accept_counter',
+ );
+
+ assert.ok(guestNegotiationProposal, 'guest agent should bring back the host counter');
+ approveProposal(state, guestNegotiationProposal.id);
+
+ const confirmed = state.bookings.find((booking) => booking.id === inquiry.id);
+ assert.equal(confirmed.stage, 'booked');
+ assert.ok(
+ confirmed.thread.some((message) => message.from === 'hostAgent'),
+ 'agent-to-agent messages should be recorded visibly',
+ );
+});
+
+test('runs full in-stay issue escalation to admin and back to resolution', () => {
+ const state = createInitialState();
+ runAllAgentLoops(state);
+ approveProposal(
+ state,
+ state.proposals.find((proposal) => proposal.status === 'pending' && proposal.humanRole === 'guest').id,
+ );
+ runAllAgentLoops(state);
+ approveProposal(
+ state,
+ state.proposals.find((proposal) => proposal.status === 'pending' && proposal.humanRole === 'host').id,
+ );
+ runAllAgentLoops(state);
+ approveProposal(
+ state,
+ state.proposals.find(
+ (proposal) => proposal.status === 'pending' && proposal.action.type === 'accept_counter',
+ ).id,
+ );
+
+ const booking = state.bookings[0];
+ advanceDay(state, booking.startDay - state.currentDay);
+ runAllAgentLoops(state);
+ approveProposal(
+ state,
+ state.proposals.find(
+ (proposal) => proposal.status === 'pending' && proposal.action.type === 'check_in_guest',
+ ).id,
+ );
+
+ reportStayIssue(state, {
+ bookingId: booking.id,
+ summary: 'Wi‑Fi outage blocks remote work',
+ severity: 'high',
+ });
+
+ runAllAgentLoops(state);
+ const hostIssueProposal = state.proposals.find(
+ (proposal) => proposal.status === 'pending' && proposal.action.type === 'offer_issue_resolution',
+ );
+ assert.ok(hostIssueProposal, 'host agent should propose an issue response');
+ rejectProposal(state, hostIssueProposal.id);
+
+ runAllAgentLoops(state);
+ const guestEscalation = state.proposals.find(
+ (proposal) => proposal.status === 'pending' && proposal.action.type === 'escalate_dispute',
+ );
+ assert.ok(guestEscalation, 'guest agent should propose escalating unresolved issues');
+ approveProposal(state, guestEscalation.id);
+
+ runAllAgentLoops(state);
+ const adminProposal = state.proposals.find(
+ (proposal) => proposal.status === 'pending' && proposal.humanRole === 'admin',
+ );
+ assert.ok(adminProposal, 'admin should receive a mediation proposal');
+ approveProposal(state, adminProposal.id);
+
+ const resolvedBooking = state.bookings.find((candidate) => candidate.id === booking.id);
+ assert.equal(resolvedBooking.issue.status, 'resolved');
+ assert.equal(resolvedBooking.dispute.status, 'closed');
+ assert.ok(resolvedBooking.financials.adjustments.length > 0, 'resolution should create a refund or credit');
+});
+
+test('supports multiple concurrent stays, dynamic pricing proposals, and persistence', () => {
+ const state = createInitialState();
+ runAllAgentLoops(state);
+
+ const initialGuestProposals = state.proposals.filter(
+ (proposal) => proposal.status === 'pending' && proposal.humanRole === 'guest',
+ );
+ assert.ok(initialGuestProposals.length >= 2, 'seeded state should allow multiple stay proposals');
+
+ for (const proposal of initialGuestProposals.slice(0, 2)) {
+ approveProposal(state, proposal.id);
+ }
+
+ runAllAgentLoops(state);
+ for (const proposal of state.proposals.filter(
+ (candidate) => candidate.status === 'pending' && candidate.humanRole === 'host',
+ )) {
+ approveProposal(state, proposal.id);
+ }
+
+ runAllAgentLoops(state);
+ for (const proposal of state.proposals.filter(
+ (candidate) => candidate.status === 'pending' && candidate.action.type === 'accept_counter',
+ )) {
+ approveProposal(state, proposal.id);
+ }
+
+ const bookedTrips = state.bookings.filter((booking) => booking.stage === 'booked');
+ assert.ok(bookedTrips.length >= 2, 'guest should be able to maintain multiple active bookings');
+
+ state.listings.forEach((listing) => {
+ listing.availabilityStrategy = 'underbooked';
+ });
+ runAllAgentLoops(state);
+ assert.ok(
+ state.proposals.some(
+ (proposal) => proposal.status === 'pending' && proposal.action.type === 'adjust_price',
+ ),
+ 'host agent should propose pricing updates',
+ );
+
+ const restored = hydrateState(serializeState(state));
+ assert.equal(restored.bookings.length, state.bookings.length);
+ assert.equal(restored.listings.length, state.listings.length);
+ assert.equal(restored.activityLog.length, state.activityLog.length);
+});
diff --git a/deliverable/stayflow_agents/tests/ui-smoke.test.mjs b/deliverable/stayflow_agents/tests/ui-smoke.test.mjs
new file mode 100644
index 000000000..d3081e428
--- /dev/null
+++ b/deliverable/stayflow_agents/tests/ui-smoke.test.mjs
@@ -0,0 +1,88 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+class FakeElement {
+ constructor() {
+ this.innerHTML = '';
+ this.listeners = new Map();
+ }
+
+ addEventListener(type, handler) {
+ this.listeners.set(type, handler);
+ }
+
+ dispatch(type, event) {
+ const handler = this.listeners.get(type);
+ if (handler) handler(event);
+ }
+}
+
+function createStorage() {
+ const store = new Map();
+ return {
+ getItem(key) {
+ return store.has(key) ? store.get(key) : null;
+ },
+ setItem(key, value) {
+ store.set(key, String(value));
+ },
+ removeItem(key) {
+ store.delete(key);
+ },
+ dump(key) {
+ return store.get(key);
+ },
+ };
+}
+
+test('browser UI renders and approves a booking inquiry through the click layer', async () => {
+ const appEl = new FakeElement();
+ const localStorage = createStorage();
+
+ global.window = {
+ localStorage,
+ setInterval() {
+ return 1;
+ },
+ };
+
+ global.document = {
+ querySelector(selector) {
+ if (selector === '#app') return appEl;
+ return null;
+ },
+ };
+
+ const moduleUrl = `${pathToFileURL(path.resolve('deliverable/app.mjs')).href}?ui_smoke=${Date.now()}`;
+ await import(moduleUrl);
+
+ assert.match(appEl.innerHTML, /StayFlow Agents/);
+ assert.match(appEl.innerHTML, /Guest approvals/);
+
+ const initialState = JSON.parse(localStorage.dump('stayflow-rental-marketplace-v1'));
+ const firstGuestProposal = initialState.proposals.find(
+ (proposal) => proposal.status === 'pending' && proposal.humanRole === 'guest',
+ );
+ assert.ok(firstGuestProposal, 'expected a guest proposal in local storage');
+
+ appEl.dispatch('click', {
+ target: {
+ closest(selector) {
+ if (selector === 'button[data-action]') {
+ return { dataset: { action: 'approve-proposal', id: firstGuestProposal.id } };
+ }
+ return null;
+ },
+ },
+ });
+
+ const afterApproval = JSON.parse(localStorage.dump('stayflow-rental-marketplace-v1'));
+ assert.equal(afterApproval.bookings.length, 1);
+ assert.equal(afterApproval.bookings[0].stage, 'inquiry');
+ assert.match(appEl.innerHTML, /Bookings, negotiation threads, and stay monitoring/);
+
+ delete global.window;
+ delete global.document;
+});
diff --git a/deliverable/texforge/README.md b/deliverable/texforge/README.md
new file mode 100644
index 000000000..488083d2c
--- /dev/null
+++ b/deliverable/texforge/README.md
@@ -0,0 +1,35 @@
+# TexForge
+
+TexForge is a full-stack collaborative LaTeX editing platform prototype inspired by Overleaf. It ships as a Python-first local app with:
+
+- project and file lifecycle management (create, clone, archive, delete, move/rename, delete files)
+- template marketplace with search, preview, and one-click project instantiation (IEEE, ACM, thesis, resume)
+- responsive split-view editor and PDF preview
+- real-time collaboration over WebSockets with presence and cursor updates
+- email/password authentication with cookie sessions
+- role-aware sharing (owner/editor/viewer), project memberships, and org-style dashboards
+- threaded comments with resolve/unresolve, suggestions, snapshots, explicit lightweight branches, diffing, restore, sharing links, notifications, and activity timeline
+- bibliography import and citation autocomplete for DOI/arXiv/Scholar-style flows
+- compile job queue simulation with downloadable PDF and ZIP export
+- offline deterministic AI assist endpoint for LaTeX generation/fixes
+- admin metrics for users, projects, memberships, compile jobs, and reference usage
+
+## Run
+
+```bash
+uv run python -m uvicorn run:app --reload
+```
+
+Then open http://127.0.0.1:8000
+
+## Test
+
+```bash
+PYTHONPATH=. UV_CACHE_DIR=.massgen_scratch/uv-cache uv run pytest tests/test_texforge.py -q -p no:cacheprovider
+```
+
+## Notes
+
+- This environment does not include Docker or TeX Live, so the compile layer is implemented as a worker-style simulation with real logs, artifacts, and extension points.
+- The collaboration and product architecture are designed so a real Yjs/worker/object-storage stack can replace the local services without changing the product surface.
+- Guest mode remains available for local demo browsing, while authenticated users unlock permissions, memberships, reference tools, and admin metrics.
diff --git a/deliverable/texforge/run.py b/deliverable/texforge/run.py
new file mode 100644
index 000000000..bd6d2ea8c
--- /dev/null
+++ b/deliverable/texforge/run.py
@@ -0,0 +1,3 @@
+from texforge.app import create_app
+
+app = create_app()
diff --git a/deliverable/texforge/tasks/changedoc.md b/deliverable/texforge/tasks/changedoc.md
new file mode 100644
index 000000000..f9a0e112f
--- /dev/null
+++ b/deliverable/texforge/tasks/changedoc.md
@@ -0,0 +1,79 @@
+# Change Document
+
+**Sources reviewed:** agent1.2, agent_a
+
+## Summary
+Delivered the final TexForge workspace as a runnable FastAPI-based collaborative LaTeX platform slice with authenticated projects, multi-file editing, template instantiation, WebSocket collaboration, threaded review, compile/export simulation, references, snapshots/diffs, and explicit lightweight branches. The final code keeps the Python-first architecture from earlier rounds and preserves the expanded marketplace, lifecycle, review, and branching workflows introduced in coordination.
+
+## Decisions
+
+### DEC-001: Keep the Python-first full-stack architecture
+**Origin:** agent1.1 → agent1.2 (kept)
+**Choice:** Implement TexForge with FastAPI, Jinja templates, vanilla JavaScript, SQLite, and WebSockets.
+**Why:** The available environment supports a strong Python delivery path without requiring a JS build toolchain, so this architecture maximizes runnable product surface area while still covering frontend, backend, persistence, and realtime behavior.
+**Alternatives considered:**
+- Rebuild the UI in a richer frontend stack: rejected because it would reduce shipped functionality in the current environment.
+- Limit the deliverable to API-only behavior: rejected because the task calls for a full-stack platform.
+**Implementation:**
+- `texforge/app.py` → `create_app()` wires the HTTP, HTML, auth, compile, review, search, sharing, reference, branch, and admin flows.
+- `texforge/db.py` → `Database` owns the SQLite schema and persistence helpers for projects, files, comments, snapshots, branches, references, and memberships.
+- `texforge/templates/dashboard.html`, `texforge/templates/project.html`, `texforge/static/app.js`, and `texforge/static/style.css` provide the browser-facing product surface.
+
+### DEC-002: Promote templates into a usable marketplace workflow
+**Origin:** agent_a NEW (extends agent1.2)
+**Choice:** Support searchable template listing, preview-by-slug, and one-step project creation from a selected template.
+**Why:** Template support is much more useful when users can browse, inspect, and instantiate a template directly instead of treating it as a static catalog entry.
+**Alternatives considered:**
+- Keep template use implicit inside generic project creation: rejected because it hides a major requested workflow.
+- Add community submission/moderation before browse/preview/use flows: rejected because core marketplace usability comes first.
+**Implementation:**
+- `texforge/app.py` → `api_templates()`, `api_template_preview()`, `api_create_project_from_template()`.
+- `texforge/db.py` → `search_templates()`.
+- `texforge/templates/dashboard.html` and `texforge/static/app.js` → dashboard search, preview, and create-from-template interactions.
+- `tests/test_texforge.py` → `test_template_marketplace_preview_search_and_instantiation()`.
+
+### DEC-003: Complete the core project and file lifecycle
+**Origin:** agent_a NEW (extends agent1.2)
+**Choice:** Add file move/rename, file delete, and project delete flows on top of existing create, clone, and archive behavior.
+**Why:** The platform brief explicitly requires create/delete/archive project management and practical organization of multi-file LaTeX trees.
+**Alternatives considered:**
+- Prioritize a richer tree UI before lifecycle correctness: rejected because backend lifecycle completeness is the stronger foundation.
+- Leave deletion out to avoid destructive actions: rejected because missing delete flows would leave the platform operationally incomplete.
+**Implementation:**
+- `texforge/app.py` → `api_move_file()`, `api_delete_file()`, `api_delete_project()`.
+- `texforge/db.py` → `move_file()`, `delete_file()`, `delete_project()`.
+- `texforge/templates/project.html` and `texforge/static/app.js` → file action controls and destructive-action handlers.
+- `tests/test_texforge.py` → `test_file_move_delete_and_project_delete()`.
+
+### DEC-004: Turn review into threaded, stateful collaboration
+**Origin:** agent_a NEW (extends agent1.2)
+**Choice:** Expose threaded comment retrieval and resolve/unresolve actions, while preserving suggestions and acceptance flows.
+**Why:** Academic review needs discussion threads that can be worked through and closed, not only flat comments.
+**Alternatives considered:**
+- Keep replies stored but not surfaced as threads: rejected because the UI would still behave like flat review.
+- Focus only on suggestion acceptance: rejected because threaded review improves the wider collaboration loop.
+**Implementation:**
+- `texforge/app.py` → `api_comment()`, `api_list_comments()`, `api_resolve_comment()`, `api_unresolve_comment()`, `api_suggestion()`, `api_accept_suggestion()`.
+- `texforge/db.py` → `list_comment_threads()`, `set_comment_resolved()`, `create_suggestion()`, `accept_suggestion()`.
+- `texforge/static/app.js` → threaded comment rendering and resolution toggles.
+- `tests/test_texforge.py` → `test_threaded_comments_resolution_and_branches()` plus existing suggestion coverage.
+
+### DEC-005: Represent lightweight branching as an explicit restorable object
+**Origin:** agent_a NEW (extends agent1.2)
+**Choice:** Persist named branches anchored to snapshots, list them separately, and allow branch restore through the API and UI.
+**Why:** The requested version-control surface includes lightweight branching and named restore points; explicit branch objects are clearer and more demonstrable than implied branches.
+**Alternatives considered:**
+- Treat snapshots alone as informal branches: rejected because that leaves the branching workflow ambiguous.
+- Attempt full git-style merge semantics locally: rejected because named branch creation and restore delivers the higher-value lightweight slice here.
+**Implementation:**
+- `texforge/app.py` → `api_create_branch()`, `api_list_branches()`, `api_restore_branch()`.
+- `texforge/db.py` → `create_branch()`, `list_branches()`, `restore_branch()` and the `branches` table.
+- `texforge/templates/project.html` and `texforge/static/app.js` → branch creation, listing, and restore controls.
+- `tests/test_texforge.py` → `test_threaded_comments_resolution_and_branches()`.
+
+## Deliberation Trail
+- **agent1.1 → agent1.2:** Established the runnable Python-first TexForge architecture and broad product slice.
+- **agent_a NEW:** Completed the template gallery into a searchable, previewable marketplace with direct instantiation.
+- **agent_a NEW:** Closed basic lifecycle gaps by adding file move/delete and project delete operations.
+- **agent_a NEW:** Finished the latent threaded review model with explicit resolve/unresolve flows.
+- **agent_a NEW:** Elevated snapshots into explicit lightweight branches with restore behavior.
diff --git a/deliverable/texforge/tests/test_texforge.py b/deliverable/texforge/tests/test_texforge.py
new file mode 100644
index 000000000..fdf7a5956
--- /dev/null
+++ b/deliverable/texforge/tests/test_texforge.py
@@ -0,0 +1,481 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+from pathlib import Path
+import socket
+import subprocess
+import sys
+import time
+
+import httpx
+from fastapi.testclient import TestClient
+import websockets
+
+from texforge.app import create_app
+from texforge.db import Database
+
+
+def make_client(tmp_path: Path) -> TestClient:
+ db_path = tmp_path / "texforge.db"
+ database = Database(db_path)
+ app = create_app(database=database)
+ return TestClient(app)
+
+
+def register_and_login(client: TestClient, email: str, password: str = "secret123", name: str = "User") -> dict:
+ register = client.post(
+ "/auth/register",
+ json={"email": email, "password": password, "name": name},
+ )
+ assert register.status_code == 200
+ login = client.post("/auth/login", json={"email": email, "password": password})
+ assert login.status_code == 200
+ return login.json()
+
+
+def free_port() -> int:
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+
+def wait_for_server(base_url: str) -> None:
+ for _ in range(50):
+ try:
+ response = httpx.get(f"{base_url}/health", timeout=1.0)
+ if response.status_code == 200:
+ return
+ except Exception:
+ time.sleep(0.1)
+ raise RuntimeError("server did not start in time")
+
+
+def test_dashboard_renders_seeded_content(tmp_path: Path) -> None:
+ client = make_client(tmp_path)
+
+ response = client.get("/")
+
+ assert response.status_code == 200
+ body = response.text
+ assert "TexForge" in body
+ assert "IEEE Conference" in body
+ assert "Quantum Notes" in body
+ assert "Live collaboration" in body
+
+
+def test_project_lifecycle_compile_and_export(tmp_path: Path) -> None:
+ client = make_client(tmp_path)
+
+ create_response = client.post(
+ "/api/projects",
+ json={
+ "name": "My Paper",
+ "template": "acm",
+ "owner": "alice@example.com",
+ },
+ )
+ assert create_response.status_code == 200
+ project = create_response.json()
+ project_id = project["id"]
+
+ file_response = client.post(
+ f"/api/projects/{project_id}/files",
+ json={
+ "path": "sections/intro.tex",
+ "content": "\\section{Intro} Collaborative writing.",
+ },
+ )
+ assert file_response.status_code == 200
+ assert file_response.json()["path"] == "sections/intro.tex"
+
+ clone_response = client.post(f"/api/projects/{project_id}/clone")
+ assert clone_response.status_code == 200
+ assert clone_response.json()["name"].startswith("My Paper (Clone")
+
+ compile_response = client.post(
+ f"/api/projects/{project_id}/compile",
+ json={"engine": "xelatex", "entrypoint": "main.tex", "trigger": "manual"},
+ )
+ assert compile_response.status_code == 200
+ job = compile_response.json()
+ assert job["status"] == "completed"
+ assert "xelatex" in job["log"]
+ assert job["pdf_url"].endswith(".pdf")
+
+ job_response = client.get(f"/api/projects/{project_id}/jobs/{job['id']}")
+ assert job_response.status_code == 200
+ assert job_response.json()["status"] == "completed"
+
+ export_response = client.get(f"/api/projects/{project_id}/export.zip")
+ assert export_response.status_code == 200
+ assert export_response.headers["content-type"] == "application/zip"
+
+ archive_response = client.post(f"/api/projects/{project_id}/archive")
+ assert archive_response.status_code == 200
+ assert archive_response.json()["archived"] is True
+
+
+def test_comments_snapshots_diff_search_and_ai(tmp_path: Path) -> None:
+ client = make_client(tmp_path)
+
+ project = client.post(
+ "/api/projects",
+ json={"name": "Review Draft", "template": "ieee", "owner": "reviewer@example.com"},
+ ).json()
+ project_id = project["id"]
+
+ main_file = client.post(
+ f"/api/projects/{project_id}/files",
+ json={"path": "main.tex", "content": "\\section{Results} First draft."},
+ ).json()
+ file_id = main_file["id"]
+
+ snapshot_a = client.post(
+ f"/api/projects/{project_id}/snapshots",
+ json={"name": "Draft A", "file_id": file_id},
+ )
+ assert snapshot_a.status_code == 200
+
+ update = client.put(
+ f"/api/files/{file_id}",
+ json={"content": "\\section{Results} Revised draft with citation \\cite{smith2024}."},
+ )
+ assert update.status_code == 200
+
+ snapshot_b = client.post(
+ f"/api/projects/{project_id}/snapshots",
+ json={"name": "Draft B", "file_id": file_id},
+ )
+ assert snapshot_b.status_code == 200
+
+ diff_response = client.get(
+ f"/api/projects/{project_id}/diff",
+ params={"from_snapshot": snapshot_a.json()["id"], "to_snapshot": snapshot_b.json()["id"]},
+ )
+ assert diff_response.status_code == 200
+ assert "Revised draft" in diff_response.json()["diff"]
+
+ comment_response = client.post(
+ f"/api/projects/{project_id}/comments",
+ json={
+ "file_id": file_id,
+ "author": "prof@example.com",
+ "body": "Please strengthen the literature review.",
+ "line_from": 1,
+ "line_to": 1,
+ },
+ )
+ assert comment_response.status_code == 200
+
+ search_response = client.get("/api/search", params={"q": "literature review"})
+ assert search_response.status_code == 200
+ assert any(item["kind"] == "comment" for item in search_response.json()["results"])
+
+ ai_response = client.post(
+ f"/api/projects/{project_id}/ai/assist",
+ json={
+ "prompt": "Write a LaTeX table for experiment results with accuracy and F1 columns.",
+ "mode": "generate",
+ },
+ )
+ assert ai_response.status_code == 200
+ assert "tabular" in ai_response.json()["suggestion"]
+
+
+def test_auth_permissions_sharing_and_admin_metrics(tmp_path: Path) -> None:
+ owner_client = make_client(tmp_path)
+ bob_client = make_client(tmp_path)
+
+ owner = register_and_login(owner_client, "alice@example.com", name="Alice")
+ register_and_login(bob_client, "bob@example.com", name="Bob")
+
+ project = owner_client.post(
+ "/api/projects",
+ json={"name": "Secured Draft", "template": "ieee", "owner": owner["email"]},
+ ).json()
+ project_id = project["id"]
+
+ denied = bob_client.get(f"/api/projects/{project_id}/files")
+ assert denied.status_code == 403
+
+ share = owner_client.post(
+ f"/api/projects/{project_id}/share-links",
+ json={"role": "editor", "expires_in_days": 7},
+ )
+ assert share.status_code == 200
+ share_id = share.json()["id"]
+
+ accept = bob_client.post(f"/api/share/{share_id}/accept")
+ assert accept.status_code == 200
+ assert accept.json()["role"] == "editor"
+
+ file_response = bob_client.post(
+ f"/api/projects/{project_id}/files",
+ json={"path": "sections/method.tex", "content": "\\section{Method} Shared edits."},
+ )
+ assert file_response.status_code == 200
+
+ admin = owner_client.get("/api/admin/metrics")
+ assert admin.status_code == 200
+ metrics = admin.json()
+ assert metrics["users"] >= 2
+ assert metrics["projects"] >= 1
+ assert metrics["memberships"] >= 2
+
+
+def test_suggestions_references_and_snapshot_restore(tmp_path: Path) -> None:
+ client = make_client(tmp_path)
+ owner = register_and_login(client, "writer@example.com", name="Writer")
+
+ project = client.post(
+ "/api/projects",
+ json={"name": "Reference Draft", "template": "ieee", "owner": owner["email"]},
+ ).json()
+ project_id = project["id"]
+ main_file = next(item for item in client.get(f"/api/projects/{project_id}/files").json() if item["path"] == "main.tex")
+
+ suggestion = client.post(
+ f"/api/projects/{project_id}/suggestions",
+ json={
+ "file_id": main_file["id"],
+ "author": "reviewer@example.com",
+ "body": "Use a stronger introduction sentence.",
+ "original_text": "Write here.",
+ "suggested_text": "Write here with stronger framing and a clearer motivation.",
+ },
+ )
+ assert suggestion.status_code == 200
+
+ accepted = client.post(f"/api/projects/{project_id}/suggestions/{suggestion.json()['id']}/accept")
+ assert accepted.status_code == 200
+ assert "stronger framing" in accepted.json()["file"]["content"]
+
+ imported = client.post(
+ f"/api/projects/{project_id}/references/import",
+ json={"source": "doi", "identifier": "10.5555/texforge-demo"},
+ )
+ assert imported.status_code == 200
+ citation_key = imported.json()["citation_key"]
+ assert citation_key
+
+ duplicate = client.post(
+ f"/api/projects/{project_id}/references/import",
+ json={"source": "doi", "identifier": "10.5555/texforge-demo"},
+ )
+ assert duplicate.status_code == 200
+ assert duplicate.json()["duplicate"] is True
+
+ autocomplete = client.get(f"/api/projects/{project_id}/citations", params={"q": "texforge"})
+ assert autocomplete.status_code == 200
+ assert any(item["citation_key"] == citation_key for item in autocomplete.json()["results"])
+
+ snapshot = client.post(
+ f"/api/projects/{project_id}/snapshots",
+ json={"name": "Accepted suggestion", "file_id": main_file["id"]},
+ ).json()
+
+ client.put(
+ f"/api/files/{main_file['id']}",
+ json={"content": "\\section{Introduction} Diverged draft."},
+ )
+ restored = client.post(f"/api/projects/{project_id}/snapshots/{snapshot['id']}/restore")
+ assert restored.status_code == 200
+ assert "stronger framing" in restored.json()["content"]
+
+
+def test_websocket_collaboration_broadcasts_presence_and_edits(tmp_path: Path) -> None:
+ db_path = tmp_path / "live_ws.db"
+ port = free_port()
+ base_url = f"http://127.0.0.1:{port}"
+ env = {
+ **os.environ,
+ "PYTHONPATH": ".",
+ "TEXFORGE_DB_PATH": str(db_path),
+ }
+ server = subprocess.Popen(
+ [sys.executable, "-m", "uvicorn", "run:app", "--port", str(port)],
+ cwd=str(Path(__file__).resolve().parents[1]),
+ env=env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ )
+ try:
+ wait_for_server(base_url)
+ project = httpx.post(
+ f"{base_url}/api/projects",
+ json={"name": "Realtime", "template": "thesis", "owner": "alice@example.com"},
+ timeout=5.0,
+ ).json()
+ project_id = project["id"]
+
+ async def exercise() -> None:
+ alice = await websockets.connect(f"ws://127.0.0.1:{port}/ws/projects/{project_id}?user=alice")
+ alice_join = json.loads(await alice.recv())
+ assert alice_join["type"] == "sync"
+
+ bob = await websockets.connect(f"ws://127.0.0.1:{port}/ws/projects/{project_id}?user=bob")
+ bob_initial = json.loads(await bob.recv())
+ assert bob_initial["type"] == "sync"
+
+ alice_presence = json.loads(await alice.recv())
+ assert alice_presence["type"] == "presence"
+ assert alice_presence["user"] == "bob"
+
+ await bob.send(json.dumps({"type": "edit", "path": "main.tex", "content": "\\section{Realtime} Hello from Bob."}))
+ alice_edit = json.loads(await alice.recv())
+ assert alice_edit["type"] == "edit"
+ assert alice_edit["content"].endswith("Hello from Bob.")
+
+ await alice.send(json.dumps({"type": "cursor", "path": "main.tex", "line": 3, "column": 7}))
+ bob_cursor = json.loads(await bob.recv())
+ assert bob_cursor["type"] == "cursor"
+ assert bob_cursor["line"] == 3
+ await bob.close()
+ await alice.close()
+
+ asyncio.run(exercise())
+ finally:
+ server.terminate()
+ try:
+ server.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ server.kill()
+
+
+def test_template_marketplace_preview_search_and_instantiation(tmp_path: Path) -> None:
+ client = make_client(tmp_path)
+
+ listing = client.get('/api/templates', params={'q': 'resume'})
+ assert listing.status_code == 200
+ templates = listing.json()['results']
+ assert len(templates) == 1
+ assert templates[0]['slug'] == 'resume'
+
+ preview = client.get('/api/templates/ieee')
+ assert preview.status_code == 200
+ assert 'main_tex' in preview.json()
+ assert 'Sample Reference' in preview.json()['refs_bib']
+
+ created = client.post(
+ '/api/projects/from-template',
+ json={'name': 'Instantiated from gallery', 'template': 'resume', 'owner': 'gallery@example.com'},
+ )
+ assert created.status_code == 200
+ project = created.json()
+ assert project['template'] == 'resume'
+ assert any(file['path'] == 'main.tex' for file in project['files'])
+
+
+
+def test_file_move_delete_and_project_delete(tmp_path: Path) -> None:
+ client = make_client(tmp_path)
+ project = client.post(
+ '/api/projects',
+ json={'name': 'Lifecycle', 'template': 'acm', 'owner': 'alice@example.com'},
+ ).json()
+ project_id = project['id']
+
+ created = client.post(
+ f'/api/projects/{project_id}/files',
+ json={'path': 'sections/intro.tex', 'content': '\\section{Intro} draft'},
+ )
+ assert created.status_code == 200
+ file_id = created.json()['id']
+
+ moved = client.patch(f'/api/files/{file_id}/move', json={'path': 'sections/background.tex'})
+ assert moved.status_code == 200
+ assert moved.json()['path'] == 'sections/background.tex'
+
+ files_after_move = client.get(f'/api/projects/{project_id}/files')
+ assert files_after_move.status_code == 200
+ paths = [item['path'] for item in files_after_move.json()]
+ assert 'sections/background.tex' in paths
+ assert 'sections/intro.tex' not in paths
+
+ deleted_file = client.delete(f'/api/files/{file_id}')
+ assert deleted_file.status_code == 200
+ assert deleted_file.json()['deleted'] is True
+
+ deleted_project = client.delete(f'/api/projects/{project_id}')
+ assert deleted_project.status_code == 200
+ assert deleted_project.json()['deleted'] is True
+
+ missing = client.get(f'/api/projects/{project_id}/files')
+ assert missing.status_code == 404
+
+
+
+def test_threaded_comments_resolution_and_branches(tmp_path: Path) -> None:
+ client = make_client(tmp_path)
+ project = client.post(
+ '/api/projects',
+ json={'name': 'Branching Draft', 'template': 'ieee', 'owner': 'alice@example.com'},
+ ).json()
+ project_id = project['id']
+ main_file = next(item for item in client.get(f'/api/projects/{project_id}/files').json() if item['path'] == 'main.tex')
+
+ parent = client.post(
+ f'/api/projects/{project_id}/comments',
+ json={
+ 'file_id': main_file['id'],
+ 'author': 'reviewer@example.com',
+ 'body': 'Please revise the introduction.',
+ 'line_from': 1,
+ 'line_to': 1,
+ },
+ )
+ assert parent.status_code == 200
+
+ reply = client.post(
+ f'/api/projects/{project_id}/comments',
+ json={
+ 'file_id': main_file['id'],
+ 'author': 'author@example.com',
+ 'body': 'Will do.',
+ 'line_from': 1,
+ 'line_to': 1,
+ 'parent_id': parent.json()['id'],
+ },
+ )
+ assert reply.status_code == 200
+
+ thread = client.get(f'/api/projects/{project_id}/comments')
+ assert thread.status_code == 200
+ thread_data = thread.json()['results']
+ root = next(item for item in thread_data if item['id'] == parent.json()['id'])
+ assert root['reply_count'] == 1
+ assert root['replies'][0]['id'] == reply.json()['id']
+
+ resolved = client.post(f"/api/projects/{project_id}/comments/{parent.json()['id']}/resolve")
+ assert resolved.status_code == 200
+ assert resolved.json()['resolved'] is True
+
+ unresolved = client.post(f"/api/projects/{project_id}/comments/{parent.json()['id']}/unresolve")
+ assert unresolved.status_code == 200
+ assert unresolved.json()['resolved'] is False
+
+ snapshot = client.post(
+ f'/api/projects/{project_id}/snapshots',
+ json={'name': 'Base draft', 'file_id': main_file['id']},
+ ).json()
+ branch = client.post(
+ f'/api/projects/{project_id}/branches',
+ json={'name': 'journal-revision', 'snapshot_id': snapshot['id']},
+ )
+ assert branch.status_code == 200
+ assert branch.json()['name'] == 'journal-revision'
+
+ listed = client.get(f'/api/projects/{project_id}/branches')
+ assert listed.status_code == 200
+ assert any(item['id'] == branch.json()['id'] for item in listed.json()['results'])
+
+ client.put(
+ f"/api/files/{main_file['id']}",
+ json={'content': '\\section{Introduction} Diverged text.'},
+ )
+ restored = client.post(f"/api/projects/{project_id}/branches/{branch.json()['id']}/restore")
+ assert restored.status_code == 200
+ assert 'Write here.' in restored.json()['content']
diff --git a/deliverable/texforge/texforge/__init__.py b/deliverable/texforge/texforge/__init__.py
new file mode 100644
index 000000000..b94a1e85f
--- /dev/null
+++ b/deliverable/texforge/texforge/__init__.py
@@ -0,0 +1,3 @@
+from .app import create_app
+
+__all__ = ["create_app"]
diff --git a/deliverable/texforge/texforge/app.py b/deliverable/texforge/texforge/app.py
new file mode 100644
index 000000000..e0b5f8739
--- /dev/null
+++ b/deliverable/texforge/texforge/app.py
@@ -0,0 +1,455 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Any
+
+from fastapi import FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse, HTMLResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+from pydantic import BaseModel
+
+from .db import Database
+from .services import CompileService, ai_assist, build_diff
+
+
+class ProjectCreate(BaseModel):
+ name: str
+ template: str
+ owner: str
+ description: str = ""
+ visibility: str = "private"
+
+
+class FileCreate(BaseModel):
+ path: str
+ content: str
+
+
+class FileUpdate(BaseModel):
+ content: str
+
+
+class FileMoveRequest(BaseModel):
+ path: str
+
+
+class CompileRequest(BaseModel):
+ engine: str = "pdflatex"
+ entrypoint: str = "main.tex"
+ trigger: str = "manual"
+
+
+class SnapshotRequest(BaseModel):
+ name: str
+ file_id: str
+
+
+class CommentRequest(BaseModel):
+ file_id: str
+ author: str
+ body: str
+ line_from: int = 1
+ line_to: int = 1
+ parent_id: str | None = None
+
+
+class AIRequest(BaseModel):
+ prompt: str
+ mode: str = "generate"
+
+
+class AuthRegister(BaseModel):
+ email: str
+ password: str
+ name: str
+
+
+class AuthLogin(BaseModel):
+ email: str
+ password: str
+
+
+class ShareLinkRequest(BaseModel):
+ role: str = "viewer"
+ expires_in_days: int = 14
+
+
+class SuggestionRequest(BaseModel):
+ file_id: str
+ author: str
+ body: str
+ original_text: str
+ suggested_text: str
+
+
+class ReferenceImportRequest(BaseModel):
+ source: str
+ identifier: str
+
+
+class BranchRequest(BaseModel):
+ name: str
+ snapshot_id: str
+
+
+class CollaborationHub:
+ def __init__(self, database: Database) -> None:
+ self.database = database
+ self.connections: dict[str, dict[str, WebSocket]] = {}
+
+ async def connect(self, project_id: str, user: str, websocket: WebSocket) -> None:
+ await websocket.accept()
+ self.connections.setdefault(project_id, {})[user] = websocket
+ files = {file["path"]: file["content"] for file in self.database.list_project_files(project_id)}
+ await websocket.send_json({"type": "sync", "project_id": project_id, "files": files, "users": list(self.connections[project_id].keys())})
+ await self.broadcast(project_id, {"type": "presence", "user": user, "status": "joined"}, exclude=user)
+
+ async def disconnect(self, project_id: str, user: str) -> None:
+ group = self.connections.get(project_id, {})
+ if user in group:
+ group.pop(user)
+ await self.broadcast(project_id, {"type": "presence", "user": user, "status": "left"})
+ if not group:
+ self.connections.pop(project_id, None)
+
+ async def broadcast(self, project_id: str, payload: dict[str, Any], exclude: str | None = None) -> None:
+ for member, websocket in list(self.connections.get(project_id, {}).items()):
+ if exclude and member == exclude:
+ continue
+ await websocket.send_json(payload)
+
+
+def create_app(database: Database | None = None) -> FastAPI:
+ base_dir = Path(__file__).parent
+ app = FastAPI(title="TexForge", version="0.1.0")
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
+ db = database or Database(Path(os.environ.get("TEXFORGE_DB_PATH", "texforge_data/texforge.db")))
+ compile_service = CompileService(db)
+ hub = CollaborationHub(db)
+ templates = Jinja2Templates(directory=str(base_dir / "templates"))
+ app.mount("/static", StaticFiles(directory=str(base_dir / "static")), name="static")
+
+ role_rank = {"viewer": 1, "editor": 2, "owner": 3}
+
+ def current_user(request: Request) -> dict[str, Any] | None:
+ session_id = request.cookies.get("texforge_session")
+ if not session_id:
+ return None
+ return db.get_user_by_session(session_id)
+
+ def require_login(request: Request) -> dict[str, Any]:
+ user = current_user(request)
+ if not user:
+ raise HTTPException(status_code=401, detail="Authentication required")
+ return user
+
+ def ensure_project_access(request: Request, project_id: str, minimum_role: str = "viewer") -> dict[str, Any]:
+ user = current_user(request)
+ if not user:
+ return {"role": "owner", "email": "guest", "name": "Guest Demo", "user_id": None}
+ member = db.get_project_member(project_id, user["id"])
+ if not member:
+ raise HTTPException(status_code=403, detail="You do not have access to this project")
+ if role_rank.get(member["role"], 0) < role_rank.get(minimum_role, 0):
+ raise HTTPException(status_code=403, detail="Insufficient project permissions")
+ return member
+
+ @app.get("/health")
+ def health() -> dict[str, str]:
+ return {"status": "ok"}
+
+ @app.post("/auth/register")
+ def auth_register(payload: AuthRegister) -> dict[str, Any]:
+ try:
+ return db.create_user(payload.email, payload.password, payload.name)
+ except Exception as exc:
+ raise HTTPException(status_code=400, detail="Could not register user") from exc
+
+ @app.post("/auth/login")
+ def auth_login(payload: AuthLogin, response: Response) -> dict[str, Any]:
+ user = db.authenticate_user(payload.email, payload.password)
+ if not user:
+ raise HTTPException(status_code=401, detail="Invalid credentials")
+ session_id = db.create_session(user["id"])
+ response.set_cookie("texforge_session", session_id, httponly=True, samesite="lax")
+ return user
+
+ @app.post("/auth/logout")
+ def auth_logout(request: Request, response: Response) -> dict[str, bool]:
+ session_id = request.cookies.get("texforge_session")
+ if session_id:
+ db.delete_session(session_id)
+ response.delete_cookie("texforge_session")
+ return {"ok": True}
+
+ @app.get("/api/me")
+ def api_me(request: Request) -> dict[str, Any]:
+ user = current_user(request)
+ if not user:
+ return {"authenticated": False}
+ return {"authenticated": True, **user}
+
+ @app.get("/", response_class=HTMLResponse)
+ def dashboard(request: Request) -> HTMLResponse:
+ projects = db.list_projects()
+ context = {
+ "request": request,
+ "projects": projects,
+ "templates": db.list_templates(),
+ "organizations": db.list_organizations(),
+ "notifications": db.list_notifications(),
+ "activity": db.list_activity(),
+ "active_project": projects[0] if projects else None,
+ "current_user": current_user(request),
+ "metrics": db.admin_metrics(),
+ }
+ return templates.TemplateResponse(request, "dashboard.html", context)
+
+ @app.get("/projects/{project_id}", response_class=HTMLResponse)
+ def project_page(project_id: str, request: Request) -> HTMLResponse:
+ try:
+ project = db.get_project(project_id)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail="Project not found") from exc
+ ensure_project_access(request, project_id, "viewer")
+ context = {
+ "request": request,
+ "project": project,
+ "templates": db.list_templates(),
+ "activity": db.list_activity(project_id),
+ "current_user": current_user(request),
+ }
+ return templates.TemplateResponse(request, "project.html", context)
+
+ @app.get("/api/projects")
+ def api_projects() -> list[dict[str, Any]]:
+ return db.list_projects()
+
+ @app.get("/api/templates")
+ def api_templates(q: str = "") -> dict[str, Any]:
+ return {"results": db.search_templates(q)}
+
+ @app.get("/api/templates/{slug}")
+ def api_template_preview(slug: str) -> dict[str, Any]:
+ try:
+ return db.get_template(slug)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail="Template not found") from exc
+
+ @app.post("/api/projects")
+ def api_create_project(payload: ProjectCreate, request: Request) -> dict[str, Any]:
+ user = current_user(request)
+ owner = user["email"] if user else payload.owner
+ return db.create_project(payload.name, payload.template, owner, payload.description, payload.visibility)
+
+ @app.post("/api/projects/from-template")
+ def api_create_project_from_template(payload: ProjectCreate, request: Request) -> dict[str, Any]:
+ return api_create_project(payload, request)
+
+ @app.post("/api/projects/{project_id}/files")
+ def api_create_file(project_id: str, payload: FileCreate, request: Request) -> dict[str, Any]:
+ try:
+ db.get_project(project_id)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail="Project not found") from exc
+ ensure_project_access(request, project_id, "editor")
+ return db.create_file(project_id, payload.path, payload.content)
+
+ @app.get("/api/projects/{project_id}/files")
+ def api_list_files(project_id: str, request: Request) -> list[dict[str, Any]]:
+ try:
+ db.get_project(project_id)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail="Project not found") from exc
+ ensure_project_access(request, project_id, "viewer")
+ return db.list_project_files(project_id)
+
+ @app.put("/api/files/{file_id}")
+ def api_update_file(file_id: str, payload: FileUpdate, request: Request) -> dict[str, Any]:
+ file_record = db.get_file(file_id)
+ ensure_project_access(request, file_record["project_id"], "editor")
+ return db.update_file(file_id, payload.content)
+
+ @app.patch("/api/files/{file_id}/move")
+ def api_move_file(file_id: str, payload: FileMoveRequest, request: Request) -> dict[str, Any]:
+ file_record = db.get_file(file_id)
+ ensure_project_access(request, file_record["project_id"], "editor")
+ return db.move_file(file_id, payload.path)
+
+ @app.delete("/api/files/{file_id}")
+ def api_delete_file(file_id: str, request: Request) -> dict[str, Any]:
+ file_record = db.get_file(file_id)
+ ensure_project_access(request, file_record["project_id"], "editor")
+ return db.delete_file(file_id)
+
+ @app.post("/api/projects/{project_id}/clone")
+ def api_clone_project(project_id: str, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "viewer")
+ return db.clone_project(project_id)
+
+ @app.post("/api/projects/{project_id}/archive")
+ def api_archive_project(project_id: str, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "owner")
+ return db.archive_project(project_id)
+
+ @app.delete("/api/projects/{project_id}")
+ def api_delete_project(project_id: str, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "owner")
+ return db.delete_project(project_id)
+
+ @app.post("/api/projects/{project_id}/compile")
+ def api_compile(project_id: str, payload: CompileRequest, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "editor")
+ return compile_service.compile_project(project_id, payload.engine, payload.entrypoint, payload.trigger)
+
+ @app.get("/api/projects/{project_id}/jobs/{job_id}")
+ def api_get_job(project_id: str, job_id: str, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "viewer")
+ return db.get_compile_job(project_id, job_id)
+
+ @app.get("/api/projects/{project_id}/export.zip")
+ def api_export(project_id: str, request: Request) -> Response:
+ ensure_project_access(request, project_id, "viewer")
+ data = compile_service.export_project_zip(project_id)
+ return Response(data, media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={project_id}.zip"})
+
+ @app.post("/api/projects/{project_id}/snapshots")
+ def api_snapshot(project_id: str, payload: SnapshotRequest, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "editor")
+ return db.create_snapshot(project_id, payload.file_id, payload.name)
+
+ @app.post("/api/projects/{project_id}/snapshots/{snapshot_id}/restore")
+ def api_restore_snapshot(project_id: str, snapshot_id: str, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "editor")
+ return db.restore_snapshot(snapshot_id)
+
+ @app.get("/api/projects/{project_id}/diff")
+ def api_diff(project_id: str, from_snapshot: str, to_snapshot: str, request: Request) -> dict[str, str]:
+ ensure_project_access(request, project_id, "viewer")
+ source = db.get_snapshot(from_snapshot)
+ target = db.get_snapshot(to_snapshot)
+ return {"diff": build_diff(source["content"], target["content"])}
+
+ @app.post("/api/projects/{project_id}/comments")
+ def api_comment(project_id: str, payload: CommentRequest, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "editor")
+ return db.create_comment(project_id, payload.file_id, payload.author, payload.body, payload.line_from, payload.line_to, payload.parent_id)
+
+ @app.get("/api/projects/{project_id}/comments")
+ def api_list_comments(project_id: str, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "viewer")
+ return {"results": db.list_comment_threads(project_id)}
+
+ @app.post("/api/projects/{project_id}/comments/{comment_id}/resolve")
+ def api_resolve_comment(project_id: str, comment_id: str, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "editor")
+ return db.set_comment_resolved(comment_id, True)
+
+ @app.post("/api/projects/{project_id}/comments/{comment_id}/unresolve")
+ def api_unresolve_comment(project_id: str, comment_id: str, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "editor")
+ return db.set_comment_resolved(comment_id, False)
+
+ @app.get("/api/search")
+ def api_search(q: str) -> dict[str, Any]:
+ return {"results": db.search(q)}
+
+ @app.post("/api/projects/{project_id}/ai/assist")
+ def api_ai(project_id: str, payload: AIRequest, request: Request) -> dict[str, str]:
+ ensure_project_access(request, project_id, "editor")
+ return ai_assist(payload.prompt, payload.mode)
+
+ @app.post("/api/projects/{project_id}/share-links")
+ def api_share_link(project_id: str, payload: ShareLinkRequest, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "owner")
+ return db.create_share_link(project_id, payload.role, payload.expires_in_days)
+
+ @app.post("/api/share/{share_id}/accept")
+ def api_accept_share(share_id: str, request: Request) -> dict[str, Any]:
+ user = require_login(request)
+ return db.accept_share_link(share_id, user["id"])
+
+ @app.get("/api/projects/{project_id}/members")
+ def api_members(project_id: str, request: Request) -> list[dict[str, Any]]:
+ ensure_project_access(request, project_id, "viewer")
+ return db.list_project_members(project_id)
+
+ @app.post("/api/projects/{project_id}/suggestions")
+ def api_suggestion(project_id: str, payload: SuggestionRequest, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "editor")
+ return db.create_suggestion(project_id, payload.file_id, payload.author, payload.body, payload.original_text, payload.suggested_text)
+
+ @app.post("/api/projects/{project_id}/suggestions/{suggestion_id}/accept")
+ def api_accept_suggestion(project_id: str, suggestion_id: str, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "editor")
+ return db.accept_suggestion(suggestion_id)
+
+ @app.post("/api/projects/{project_id}/references/import")
+ def api_import_reference(project_id: str, payload: ReferenceImportRequest, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "editor")
+ return db.import_reference(project_id, payload.source, payload.identifier)
+
+ @app.get("/api/projects/{project_id}/citations")
+ def api_citations(project_id: str, q: str, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "viewer")
+ return {"results": db.search_references(project_id, q)}
+
+ @app.post("/api/projects/{project_id}/branches")
+ def api_create_branch(project_id: str, payload: BranchRequest, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "editor")
+ return db.create_branch(project_id, payload.snapshot_id, payload.name)
+
+ @app.get("/api/projects/{project_id}/branches")
+ def api_list_branches(project_id: str, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "viewer")
+ return {"results": db.list_branches(project_id)}
+
+ @app.post("/api/projects/{project_id}/branches/{branch_id}/restore")
+ def api_restore_branch(project_id: str, branch_id: str, request: Request) -> dict[str, Any]:
+ ensure_project_access(request, project_id, "editor")
+ return db.restore_branch(branch_id)
+
+ @app.get("/api/admin/metrics")
+ def api_admin_metrics(request: Request) -> dict[str, Any]:
+ require_login(request)
+ return db.admin_metrics()
+
+ @app.get("/artifacts/{project_id}/{filename}")
+ def artifact(project_id: str, filename: str, request: Request) -> FileResponse:
+ ensure_project_access(request, project_id, "viewer")
+ target = db.artifact_root / project_id / filename
+ if not target.exists():
+ raise HTTPException(status_code=404, detail="Artifact not found")
+ media_type = "application/pdf" if target.suffix == ".pdf" else "text/plain"
+ return FileResponse(target, media_type=media_type)
+
+ @app.websocket("/ws/projects/{project_id}")
+ async def ws_project(project_id: str, websocket: WebSocket, user: str = "anonymous") -> None:
+ await hub.connect(project_id, user, websocket)
+ try:
+ while True:
+ message = await websocket.receive_json()
+ if message.get("type") == "edit":
+ try:
+ db.create_or_update_file(project_id, message["path"], message["content"])
+ except Exception:
+ pass
+ await hub.broadcast(project_id, {"type": "edit", "user": user, "path": message["path"], "content": message["content"]}, exclude=user)
+ elif message.get("type") == "cursor":
+ payload = {
+ "type": "cursor",
+ "user": user,
+ "path": message.get("path", "main.tex"),
+ "line": message.get("line", 1),
+ "column": message.get("column", 1),
+ }
+ await hub.broadcast(project_id, payload, exclude=user)
+ elif message.get("type") == "comment":
+ await hub.broadcast(project_id, {"type": "comment", "user": user, "body": message.get("body", "")}, exclude=user)
+ except WebSocketDisconnect:
+ await hub.disconnect(project_id, user)
+
+ return app
diff --git a/deliverable/texforge/texforge/db.py b/deliverable/texforge/texforge/db.py
new file mode 100644
index 000000000..98b43c749
--- /dev/null
+++ b/deliverable/texforge/texforge/db.py
@@ -0,0 +1,962 @@
+from __future__ import annotations
+
+import json
+import hashlib
+import sqlite3
+import uuid
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from typing import Any
+
+
+class Database:
+ def __init__(self, path: Path) -> None:
+ self.path = Path(path)
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ self.artifact_root = self.path.parent / "artifacts"
+ self.artifact_root.mkdir(parents=True, exist_ok=True)
+ self.setup()
+ self.seed()
+
+ def _connect(self) -> sqlite3.Connection:
+ connection = sqlite3.connect(self.path)
+ connection.row_factory = sqlite3.Row
+ return connection
+
+ def setup(self) -> None:
+ with self._connect() as conn:
+ conn.executescript(
+ """
+ CREATE TABLE IF NOT EXISTS templates (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ slug TEXT NOT NULL UNIQUE,
+ category TEXT NOT NULL,
+ description TEXT NOT NULL,
+ tags TEXT NOT NULL,
+ main_tex TEXT NOT NULL,
+ refs_bib TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS organizations (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ email TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'user',
+ created_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS projects (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ template TEXT NOT NULL,
+ owner TEXT NOT NULL,
+ description TEXT NOT NULL,
+ visibility TEXT NOT NULL,
+ org_id TEXT,
+ archived INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS files (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ path TEXT NOT NULL,
+ content TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ UNIQUE(project_id, path)
+ );
+
+ CREATE TABLE IF NOT EXISTS comments (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ file_id TEXT NOT NULL,
+ parent_id TEXT,
+ author TEXT NOT NULL,
+ body TEXT NOT NULL,
+ line_from INTEGER NOT NULL,
+ line_to INTEGER NOT NULL,
+ resolved INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS snapshots (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ file_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ content TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS compile_jobs (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ engine TEXT NOT NULL,
+ entrypoint TEXT NOT NULL,
+ trigger TEXT NOT NULL,
+ status TEXT NOT NULL,
+ log TEXT NOT NULL,
+ pdf_path TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS activity (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ project_id TEXT,
+ actor TEXT NOT NULL,
+ verb TEXT NOT NULL,
+ target TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS notifications (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ body TEXT NOT NULL,
+ level TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS share_links (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ role TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ url TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS project_memberships (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ role TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ UNIQUE(project_id, user_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS suggestions (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ file_id TEXT NOT NULL,
+ author TEXT NOT NULL,
+ body TEXT NOT NULL,
+ original_text TEXT NOT NULL,
+ suggested_text TEXT NOT NULL,
+ status TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS references_library (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ source TEXT NOT NULL,
+ identifier TEXT NOT NULL,
+ citation_key TEXT NOT NULL,
+ title TEXT NOT NULL,
+ authors TEXT NOT NULL,
+ year TEXT NOT NULL,
+ raw_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ UNIQUE(project_id, source, identifier)
+ );
+
+ CREATE TABLE IF NOT EXISTS branches (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ snapshot_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ );
+ """
+ )
+
+ def seed(self) -> None:
+ with self._connect() as conn:
+ template_count = conn.execute("SELECT COUNT(*) FROM templates").fetchone()[0]
+ if template_count == 0:
+ templates = [
+ {
+ "id": self._id("tpl"),
+ "name": "IEEE Conference",
+ "slug": "ieee",
+ "category": "Journal",
+ "description": "Two-column conference paper with bibliography and figure scaffold.",
+ "tags": json.dumps(["ieee", "conference", "paper"]),
+ "main_tex": "\\documentclass{article}\n\\begin{document}\n\\title{IEEE Paper}\n\\maketitle\n\\section{Introduction}\nWrite here.\\cite{smith2024}\n\\end{document}\n",
+ "refs_bib": "@article{smith2024,\n title={Sample Reference},\n author={Smith, Ada},\n journal={Journal of Examples},\n year={2024}\n}\n",
+ },
+ {
+ "id": self._id("tpl"),
+ "name": "ACM Article",
+ "slug": "acm",
+ "category": "Journal",
+ "description": "ACM submission starter with abstract and CCS concepts.",
+ "tags": json.dumps(["acm", "article"]),
+ "main_tex": "\\documentclass{article}\n\\begin{document}\n\\title{ACM Draft}\n\\maketitle\n\\begin{abstract}\nAbstract here.\n\\end{abstract}\n\\section{Method}\n\\end{document}\n",
+ "refs_bib": "@inproceedings{lee2025,\n title={Collaborative Editing at Scale},\n author={Lee, Robin},\n booktitle={ACM Example},\n year={2025}\n}\n",
+ },
+ {
+ "id": self._id("tpl"),
+ "name": "Research Thesis",
+ "slug": "thesis",
+ "category": "Thesis",
+ "description": "Multi-file thesis layout with chapters and front matter.",
+ "tags": json.dumps(["thesis", "phd"]),
+ "main_tex": "\\documentclass{report}\n\\begin{document}\n\\title{Thesis}\n\\maketitle\n\\chapter{Overview}\n\\input{chapters/ch1.tex}\n\\end{document}\n",
+ "refs_bib": "@book{doe2023,\n title={Example Thesis Book},\n author={Doe, Jane},\n year={2023}\n}\n",
+ },
+ {
+ "id": self._id("tpl"),
+ "name": "Academic Resume",
+ "slug": "resume",
+ "category": "CV",
+ "description": "One-page resume with publications and teaching sections.",
+ "tags": json.dumps(["resume", "cv"]),
+ "main_tex": "\\documentclass{article}\n\\begin{document}\n\\section*{Experience}\n\\section*{Publications}\n\\end{document}\n",
+ "refs_bib": "",
+ },
+ ]
+ conn.executemany(
+ "INSERT INTO templates (id, name, slug, category, description, tags, main_tex, refs_bib) VALUES (:id, :name, :slug, :category, :description, :tags, :main_tex, :refs_bib)",
+ templates,
+ )
+
+ if conn.execute("SELECT COUNT(*) FROM organizations").fetchone()[0] == 0:
+ conn.execute(
+ "INSERT INTO organizations (id, name, description, created_at) VALUES (?, ?, ?, ?)",
+ (self._id("org"), "TexForge Research Lab", "Shared workspace for papers, reviews, and journal submissions.", self.now()),
+ )
+
+ if conn.execute("SELECT COUNT(*) FROM notifications").fetchone()[0] == 0:
+ notifications = [
+ (self._id("note"), "Compile queue healthy", "All worker lanes are available for fast feedback.", "success", self.now()),
+ (self._id("note"), "Review mention", "@alice requested changes on Quantum Notes.", "info", self.now()),
+ ]
+ conn.executemany(
+ "INSERT INTO notifications (id, title, body, level, created_at) VALUES (?, ?, ?, ?, ?)",
+ notifications,
+ )
+ conn.commit()
+
+ with self._connect() as conn:
+ if conn.execute("SELECT COUNT(*) FROM projects").fetchone()[0] == 0:
+ org_id = conn.execute("SELECT id FROM organizations LIMIT 1").fetchone()["id"]
+ else:
+ org_id = None
+ if org_id:
+ project = self.create_project(
+ name="Quantum Notes",
+ template="ieee",
+ owner="alice@example.com",
+ description="Shared manuscript for a collaborative quantum systems paper.",
+ visibility="private",
+ org_id=org_id,
+ )
+ files = self.list_project_files(project["id"])
+ main_tex = next(f for f in files if f["path"] == "main.tex")
+ self.create_comment(
+ project["id"],
+ main_tex["id"],
+ "prof@example.com",
+ "Live collaboration note: tighten the motivation paragraph.",
+ 1,
+ 2,
+ )
+ self.create_snapshot(project["id"], main_tex["id"], "Initial draft")
+ self.record_activity(project["id"], "alice@example.com", "seeded", "Quantum Notes demo project")
+
+ @staticmethod
+ def now() -> str:
+ return datetime.now(UTC).isoformat()
+
+ @staticmethod
+ def _id(prefix: str) -> str:
+ return f"{prefix}_{uuid.uuid4().hex[:10]}"
+
+ def list_templates(self) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute("SELECT * FROM templates ORDER BY name").fetchall()
+ return [self._row_to_template(r) for r in rows]
+
+ def search_templates(self, query: str = "") -> list[dict[str, Any]]:
+ if not query.strip():
+ return self.list_templates()
+ like = f"%{query.lower()}%"
+ with self._connect() as conn:
+ rows = conn.execute(
+ """SELECT * FROM templates
+ WHERE lower(name) LIKE ? OR lower(description) LIKE ? OR lower(tags) LIKE ?
+ ORDER BY name""",
+ (like, like, like),
+ ).fetchall()
+ return [self._row_to_template(r) for r in rows]
+
+ def get_template(self, slug: str) -> dict[str, Any]:
+ with self._connect() as conn:
+ row = conn.execute("SELECT * FROM templates WHERE slug = ?", (slug,)).fetchone()
+ if row is None:
+ raise KeyError(f"Unknown template: {slug}")
+ return self._row_to_template(row)
+
+ def list_organizations(self) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute("SELECT * FROM organizations ORDER BY name").fetchall()
+ return [dict(r) for r in rows]
+
+ def list_notifications(self) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute("SELECT * FROM notifications ORDER BY created_at DESC").fetchall()
+ return [dict(r) for r in rows]
+
+ def create_user(self, email: str, password: str, name: str, role: str = "user") -> dict[str, Any]:
+ user = {
+ "id": self._id("usr"),
+ "email": email.lower(),
+ "name": name,
+ "password_hash": self._hash_password(password),
+ "role": role,
+ "created_at": self.now(),
+ }
+ with self._connect() as conn:
+ conn.execute(
+ "INSERT INTO users (id, email, name, password_hash, role, created_at) VALUES (:id, :email, :name, :password_hash, :role, :created_at)",
+ user,
+ )
+ safe_user = self.get_user_by_email(email)
+ self.record_activity(None, email.lower(), "registered", "user account")
+ return safe_user
+
+ def get_user_by_email(self, email: str) -> dict[str, Any]:
+ with self._connect() as conn:
+ row = conn.execute("SELECT id, email, name, role, created_at FROM users WHERE email = ?", (email.lower(),)).fetchone()
+ if row is None:
+ raise KeyError(email)
+ return dict(row)
+
+ def authenticate_user(self, email: str, password: str) -> dict[str, Any] | None:
+ with self._connect() as conn:
+ row = conn.execute("SELECT * FROM users WHERE email = ?", (email.lower(),)).fetchone()
+ if row is None or row["password_hash"] != self._hash_password(password):
+ return None
+ return {
+ "id": row["id"],
+ "email": row["email"],
+ "name": row["name"],
+ "role": row["role"],
+ "created_at": row["created_at"],
+ }
+
+ def create_session(self, user_id: str) -> str:
+ session_id = self._id("sess")
+ with self._connect() as conn:
+ conn.execute(
+ "INSERT INTO sessions (id, user_id, created_at) VALUES (?, ?, ?)",
+ (session_id, user_id, self.now()),
+ )
+ return session_id
+
+ def delete_session(self, session_id: str) -> None:
+ with self._connect() as conn:
+ conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
+
+ def get_user_by_session(self, session_id: str) -> dict[str, Any] | None:
+ with self._connect() as conn:
+ row = conn.execute(
+ """SELECT users.id, users.email, users.name, users.role, users.created_at
+ FROM sessions JOIN users ON users.id = sessions.user_id
+ WHERE sessions.id = ?""",
+ (session_id,),
+ ).fetchone()
+ return dict(row) if row else None
+
+ def list_projects(self) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute("SELECT * FROM projects ORDER BY updated_at DESC").fetchall()
+ return [self._row_to_project(r) for r in rows]
+
+ def get_project(self, project_id: str) -> dict[str, Any]:
+ with self._connect() as conn:
+ row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
+ if row is None:
+ raise KeyError(project_id)
+ return self._row_to_project(row)
+
+ def create_project(
+ self,
+ name: str,
+ template: str,
+ owner: str,
+ description: str = "",
+ visibility: str = "private",
+ org_id: str | None = None,
+ ) -> dict[str, Any]:
+ template_row = self.get_template(template)
+ project_id = self._id("prj")
+ now = self.now()
+ with self._connect() as conn:
+ conn.execute(
+ """INSERT INTO projects (id, name, template, owner, description, visibility, org_id, archived, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)""",
+ (project_id, name, template, owner, description or template_row["description"], visibility, org_id, now, now),
+ )
+ self.create_or_update_file(project_id, "main.tex", template_row["main_tex"])
+ self.create_or_update_file(project_id, "refs.bib", template_row["refs_bib"])
+ if template == "thesis":
+ self.create_or_update_file(project_id, "chapters/ch1.tex", "\\chapter{Introduction}\nThesis chapter placeholder.\n")
+ self.create_share_link(project_id, "editor")
+ try:
+ owner_user = self.get_user_by_email(owner)
+ except KeyError:
+ owner_user = None
+ if owner_user:
+ self.add_project_member(project_id, owner_user["id"], "owner")
+ self.record_activity(project_id, owner, "created", name)
+ return self.get_project(project_id)
+
+ def create_share_link(self, project_id: str, role: str, expires_in_days: int = 14) -> dict[str, Any]:
+ share_id = self._id("share")
+ expires = (datetime.now(UTC) + timedelta(days=expires_in_days)).isoformat()
+ url = f"/share/{share_id}"
+ with self._connect() as conn:
+ conn.execute(
+ "INSERT INTO share_links (id, project_id, role, expires_at, url) VALUES (?, ?, ?, ?, ?)",
+ (share_id, project_id, role, expires, url),
+ )
+ return {"id": share_id, "project_id": project_id, "role": role, "expires_at": expires, "url": url}
+
+ def get_share_link(self, share_id: str) -> dict[str, Any]:
+ with self._connect() as conn:
+ row = conn.execute("SELECT * FROM share_links WHERE id = ?", (share_id,)).fetchone()
+ if row is None:
+ raise KeyError(share_id)
+ return dict(row)
+
+ def accept_share_link(self, share_id: str, user_id: str) -> dict[str, Any]:
+ share = self.get_share_link(share_id)
+ self.add_project_member(share["project_id"], user_id, share["role"])
+ user = self.get_user_by_id(user_id)
+ self.record_activity(share["project_id"], user["email"], "joined", share["role"])
+ return {"project_id": share["project_id"], "user_id": user_id, "role": share["role"], "share_id": share_id}
+
+ def list_share_links(self, project_id: str) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute("SELECT * FROM share_links WHERE project_id = ? ORDER BY expires_at DESC", (project_id,)).fetchall()
+ return [dict(r) for r in rows]
+
+ def add_project_member(self, project_id: str, user_id: str, role: str) -> dict[str, Any]:
+ membership = {
+ "id": self._id("mbr"),
+ "project_id": project_id,
+ "user_id": user_id,
+ "role": role,
+ "created_at": self.now(),
+ }
+ with self._connect() as conn:
+ conn.execute(
+ """INSERT INTO project_memberships (id, project_id, user_id, role, created_at)
+ VALUES (:id, :project_id, :user_id, :role, :created_at)
+ ON CONFLICT(project_id, user_id) DO UPDATE SET role = excluded.role""",
+ membership,
+ )
+ return self.get_project_member(project_id, user_id)
+
+ def get_user_by_id(self, user_id: str) -> dict[str, Any]:
+ with self._connect() as conn:
+ row = conn.execute("SELECT id, email, name, role, created_at FROM users WHERE id = ?", (user_id,)).fetchone()
+ if row is None:
+ raise KeyError(user_id)
+ return dict(row)
+
+ def get_project_member(self, project_id: str, user_id: str) -> dict[str, Any] | None:
+ with self._connect() as conn:
+ row = conn.execute(
+ """SELECT project_memberships.*, users.email, users.name
+ FROM project_memberships JOIN users ON users.id = project_memberships.user_id
+ WHERE project_memberships.project_id = ? AND project_memberships.user_id = ?""",
+ (project_id, user_id),
+ ).fetchone()
+ return dict(row) if row else None
+
+ def list_project_members(self, project_id: str) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute(
+ """SELECT project_memberships.*, users.email, users.name
+ FROM project_memberships JOIN users ON users.id = project_memberships.user_id
+ WHERE project_memberships.project_id = ?
+ ORDER BY CASE project_memberships.role WHEN 'owner' THEN 0 WHEN 'editor' THEN 1 ELSE 2 END, users.email""",
+ (project_id,),
+ ).fetchall()
+ return [dict(row) for row in rows]
+
+ def create_or_update_file(self, project_id: str, path: str, content: str) -> dict[str, Any]:
+ with self._connect() as conn:
+ existing = conn.execute("SELECT id FROM files WHERE project_id = ? AND path = ?", (project_id, path)).fetchone()
+ now = self.now()
+ if existing:
+ conn.execute("UPDATE files SET content = ?, updated_at = ? WHERE id = ?", (content, now, existing["id"]))
+ file_id = existing["id"]
+ else:
+ file_id = self._id("file")
+ conn.execute(
+ "INSERT INTO files (id, project_id, path, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
+ (file_id, project_id, path, content, now, now),
+ )
+ conn.execute("UPDATE projects SET updated_at = ? WHERE id = ?", (now, project_id))
+ return self.get_file(file_id)
+
+ def create_file(self, project_id: str, path: str, content: str) -> dict[str, Any]:
+ file_record = self.create_or_update_file(project_id, path, content)
+ self.record_activity(project_id, "system", "file_added", path)
+ return file_record
+
+ def move_file(self, file_id: str, new_path: str) -> dict[str, Any]:
+ now = self.now()
+ with self._connect() as conn:
+ row = conn.execute("SELECT project_id, path FROM files WHERE id = ?", (file_id,)).fetchone()
+ if row is None:
+ raise KeyError(file_id)
+ conn.execute("UPDATE files SET path = ?, updated_at = ? WHERE id = ?", (new_path, now, file_id))
+ conn.execute("UPDATE projects SET updated_at = ? WHERE id = ?", (now, row["project_id"]))
+ self.record_activity(row["project_id"], "system", "file_moved", f"{row['path']} -> {new_path}")
+ return self.get_file(file_id)
+
+ def delete_file(self, file_id: str) -> dict[str, Any]:
+ file_record = self.get_file(file_id)
+ with self._connect() as conn:
+ conn.execute("DELETE FROM comments WHERE file_id = ?", (file_id,))
+ conn.execute("DELETE FROM snapshots WHERE file_id = ?", (file_id,))
+ conn.execute("DELETE FROM suggestions WHERE file_id = ?", (file_id,))
+ conn.execute("DELETE FROM files WHERE id = ?", (file_id,))
+ conn.execute("UPDATE projects SET updated_at = ? WHERE id = ?", (self.now(), file_record["project_id"]))
+ self.record_activity(file_record["project_id"], "system", "file_deleted", file_record["path"])
+ return {"deleted": True, "file_id": file_id, "path": file_record["path"]}
+
+ def list_project_files(self, project_id: str) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute("SELECT * FROM files WHERE project_id = ? ORDER BY path", (project_id,)).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_file(self, file_id: str) -> dict[str, Any]:
+ with self._connect() as conn:
+ row = conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
+ if row is None:
+ raise KeyError(file_id)
+ return dict(row)
+
+ def update_file(self, file_id: str, content: str) -> dict[str, Any]:
+ now = self.now()
+ with self._connect() as conn:
+ row = conn.execute("SELECT project_id, path FROM files WHERE id = ?", (file_id,)).fetchone()
+ if row is None:
+ raise KeyError(file_id)
+ conn.execute("UPDATE files SET content = ?, updated_at = ? WHERE id = ?", (content, now, file_id))
+ conn.execute("UPDATE projects SET updated_at = ? WHERE id = ?", (now, row["project_id"]))
+ self.record_activity(row["project_id"], "system", "file_updated", row["path"])
+ return self.get_file(file_id)
+
+ def clone_project(self, project_id: str) -> dict[str, Any]:
+ project = self.get_project(project_id)
+ clone = self.create_project(
+ name=f"{project['name']} (Clone {datetime.now(UTC).strftime('%H:%M')})",
+ template=project["template"],
+ owner=project["owner"],
+ description=project["description"],
+ visibility=project["visibility"],
+ org_id=project.get("org_id"),
+ )
+ for file in self.list_project_files(project_id):
+ self.create_or_update_file(clone["id"], file["path"], file["content"])
+ self.record_activity(clone["id"], project["owner"], "cloned_from", project["name"])
+ return clone
+
+ def archive_project(self, project_id: str) -> dict[str, Any]:
+ with self._connect() as conn:
+ conn.execute("UPDATE projects SET archived = 1, updated_at = ? WHERE id = ?", (self.now(), project_id))
+ self.record_activity(project_id, "system", "archived", project_id)
+ return self.get_project(project_id)
+
+ def delete_project(self, project_id: str) -> dict[str, Any]:
+ project = self.get_project(project_id)
+ with self._connect() as conn:
+ conn.execute("DELETE FROM branches WHERE project_id = ?", (project_id,))
+ conn.execute("DELETE FROM compile_jobs WHERE project_id = ?", (project_id,))
+ conn.execute("DELETE FROM comments WHERE project_id = ?", (project_id,))
+ conn.execute("DELETE FROM snapshots WHERE project_id = ?", (project_id,))
+ conn.execute("DELETE FROM share_links WHERE project_id = ?", (project_id,))
+ conn.execute("DELETE FROM project_memberships WHERE project_id = ?", (project_id,))
+ conn.execute("DELETE FROM suggestions WHERE project_id = ?", (project_id,))
+ conn.execute("DELETE FROM references_library WHERE project_id = ?", (project_id,))
+ conn.execute("DELETE FROM activity WHERE project_id = ?", (project_id,))
+ conn.execute("DELETE FROM files WHERE project_id = ?", (project_id,))
+ conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
+ artifact_dir = self.artifact_root / project_id
+ if artifact_dir.exists():
+ for child in artifact_dir.iterdir():
+ child.unlink()
+ artifact_dir.rmdir()
+ return {"deleted": True, "project_id": project_id, "name": project["name"]}
+
+ def create_comment(
+ self,
+ project_id: str,
+ file_id: str,
+ author: str,
+ body: str,
+ line_from: int,
+ line_to: int,
+ parent_id: str | None = None,
+ ) -> dict[str, Any]:
+ comment = {
+ "id": self._id("cmt"),
+ "project_id": project_id,
+ "file_id": file_id,
+ "parent_id": parent_id,
+ "author": author,
+ "body": body,
+ "line_from": line_from,
+ "line_to": line_to,
+ "resolved": 0,
+ "created_at": self.now(),
+ }
+ with self._connect() as conn:
+ conn.execute(
+ """INSERT INTO comments (id, project_id, file_id, parent_id, author, body, line_from, line_to, resolved, created_at)
+ VALUES (:id, :project_id, :file_id, :parent_id, :author, :body, :line_from, :line_to, :resolved, :created_at)""",
+ comment,
+ )
+ self.record_activity(project_id, author, "commented", body[:60])
+ return comment
+
+ def list_comments(self, project_id: str) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute("SELECT * FROM comments WHERE project_id = ? ORDER BY created_at DESC", (project_id,)).fetchall()
+ return [dict(r) for r in rows]
+
+ def list_comment_threads(self, project_id: str) -> list[dict[str, Any]]:
+ comments = self.list_comments(project_id)
+ by_parent: dict[str, list[dict[str, Any]]] = {}
+ roots: list[dict[str, Any]] = []
+ for comment in comments:
+ comment["resolved"] = bool(comment["resolved"])
+ comment["replies"] = []
+ comment["reply_count"] = 0
+ parent_id = comment.get("parent_id")
+ if parent_id:
+ by_parent.setdefault(parent_id, []).append(comment)
+ else:
+ roots.append(comment)
+ for root in roots:
+ replies = list(reversed(by_parent.get(root["id"], [])))
+ root["replies"] = replies
+ root["reply_count"] = len(replies)
+ return roots
+
+ def get_comment(self, comment_id: str) -> dict[str, Any]:
+ with self._connect() as conn:
+ row = conn.execute("SELECT * FROM comments WHERE id = ?", (comment_id,)).fetchone()
+ if row is None:
+ raise KeyError(comment_id)
+ comment = dict(row)
+ comment["resolved"] = bool(comment["resolved"])
+ return comment
+
+ def set_comment_resolved(self, comment_id: str, resolved: bool) -> dict[str, Any]:
+ comment = self.get_comment(comment_id)
+ with self._connect() as conn:
+ conn.execute("UPDATE comments SET resolved = ? WHERE id = ?", (1 if resolved else 0, comment_id))
+ verb = "resolved_comment" if resolved else "reopened_comment"
+ self.record_activity(comment["project_id"], comment["author"], verb, comment["body"][:60])
+ return self.get_comment(comment_id)
+
+ def create_snapshot(self, project_id: str, file_id: str, name: str) -> dict[str, Any]:
+ file_record = self.get_file(file_id)
+ snapshot = {
+ "id": self._id("snap"),
+ "project_id": project_id,
+ "file_id": file_id,
+ "name": name,
+ "content": file_record["content"],
+ "created_at": self.now(),
+ }
+ with self._connect() as conn:
+ conn.execute(
+ "INSERT INTO snapshots (id, project_id, file_id, name, content, created_at) VALUES (:id, :project_id, :file_id, :name, :content, :created_at)",
+ snapshot,
+ )
+ self.record_activity(project_id, "system", "snapshot", name)
+ return snapshot
+
+ def restore_snapshot(self, snapshot_id: str) -> dict[str, Any]:
+ snapshot = self.get_snapshot(snapshot_id)
+ restored = self.update_file(snapshot["file_id"], snapshot["content"])
+ self.record_activity(snapshot["project_id"], "system", "restored_snapshot", snapshot["name"])
+ return restored
+
+ def get_snapshot(self, snapshot_id: str) -> dict[str, Any]:
+ with self._connect() as conn:
+ row = conn.execute("SELECT * FROM snapshots WHERE id = ?", (snapshot_id,)).fetchone()
+ if row is None:
+ raise KeyError(snapshot_id)
+ return dict(row)
+
+ def list_snapshots(self, project_id: str) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute("SELECT * FROM snapshots WHERE project_id = ? ORDER BY created_at DESC", (project_id,)).fetchall()
+ return [dict(r) for r in rows]
+
+ def create_branch(self, project_id: str, snapshot_id: str, name: str) -> dict[str, Any]:
+ self.get_project(project_id)
+ snapshot = self.get_snapshot(snapshot_id)
+ branch = {
+ "id": self._id("br"),
+ "project_id": project_id,
+ "snapshot_id": snapshot["id"],
+ "name": name,
+ "created_at": self.now(),
+ }
+ with self._connect() as conn:
+ conn.execute(
+ "INSERT INTO branches (id, project_id, snapshot_id, name, created_at) VALUES (:id, :project_id, :snapshot_id, :name, :created_at)",
+ branch,
+ )
+ self.record_activity(project_id, "system", "branch_created", name)
+ return branch
+
+ def list_branches(self, project_id: str) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute("SELECT * FROM branches WHERE project_id = ? ORDER BY created_at DESC", (project_id,)).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_branch(self, branch_id: str) -> dict[str, Any]:
+ with self._connect() as conn:
+ row = conn.execute("SELECT * FROM branches WHERE id = ?", (branch_id,)).fetchone()
+ if row is None:
+ raise KeyError(branch_id)
+ return dict(row)
+
+ def restore_branch(self, branch_id: str) -> dict[str, Any]:
+ branch = self.get_branch(branch_id)
+ restored = self.restore_snapshot(branch["snapshot_id"])
+ self.record_activity(branch["project_id"], "system", "restored_branch", branch["name"])
+ return restored
+
+ def create_compile_job(self, project_id: str, engine: str, entrypoint: str, trigger: str, log: str, pdf_path: str) -> dict[str, Any]:
+ job = {
+ "id": self._id("job"),
+ "project_id": project_id,
+ "engine": engine,
+ "entrypoint": entrypoint,
+ "trigger": trigger,
+ "status": "completed",
+ "log": log,
+ "pdf_path": pdf_path,
+ "created_at": self.now(),
+ }
+ with self._connect() as conn:
+ conn.execute(
+ """INSERT INTO compile_jobs (id, project_id, engine, entrypoint, trigger, status, log, pdf_path, created_at)
+ VALUES (:id, :project_id, :engine, :entrypoint, :trigger, :status, :log, :pdf_path, :created_at)""",
+ job,
+ )
+ self.record_activity(project_id, "worker", "compiled", f"{engine}:{entrypoint}")
+ return self.get_compile_job(project_id, job["id"])
+
+ def get_compile_job(self, project_id: str, job_id: str) -> dict[str, Any]:
+ with self._connect() as conn:
+ row = conn.execute("SELECT * FROM compile_jobs WHERE project_id = ? AND id = ?", (project_id, job_id)).fetchone()
+ if row is None:
+ raise KeyError(job_id)
+ job = dict(row)
+ job["pdf_url"] = f"/artifacts/{project_id}/{Path(job['pdf_path']).name}"
+ return job
+
+ def list_activity(self, project_id: str | None = None) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ if project_id:
+ rows = conn.execute("SELECT * FROM activity WHERE project_id = ? ORDER BY created_at DESC LIMIT 20", (project_id,)).fetchall()
+ else:
+ rows = conn.execute("SELECT * FROM activity ORDER BY created_at DESC LIMIT 20").fetchall()
+ return [dict(r) for r in rows]
+
+ def record_activity(self, project_id: str | None, actor: str, verb: str, target: str) -> None:
+ with self._connect() as conn:
+ conn.execute(
+ "INSERT INTO activity (project_id, actor, verb, target, created_at) VALUES (?, ?, ?, ?, ?)",
+ (project_id, actor, verb, target, self.now()),
+ )
+
+ def create_suggestion(self, project_id: str, file_id: str, author: str, body: str, original_text: str, suggested_text: str) -> dict[str, Any]:
+ suggestion = {
+ "id": self._id("sgt"),
+ "project_id": project_id,
+ "file_id": file_id,
+ "author": author,
+ "body": body,
+ "original_text": original_text,
+ "suggested_text": suggested_text,
+ "status": "open",
+ "created_at": self.now(),
+ }
+ with self._connect() as conn:
+ conn.execute(
+ """INSERT INTO suggestions (id, project_id, file_id, author, body, original_text, suggested_text, status, created_at)
+ VALUES (:id, :project_id, :file_id, :author, :body, :original_text, :suggested_text, :status, :created_at)""",
+ suggestion,
+ )
+ self.record_activity(project_id, author, "suggested", body[:60])
+ return suggestion
+
+ def get_suggestion(self, suggestion_id: str) -> dict[str, Any]:
+ with self._connect() as conn:
+ row = conn.execute("SELECT * FROM suggestions WHERE id = ?", (suggestion_id,)).fetchone()
+ if row is None:
+ raise KeyError(suggestion_id)
+ return dict(row)
+
+ def accept_suggestion(self, suggestion_id: str) -> dict[str, Any]:
+ suggestion = self.get_suggestion(suggestion_id)
+ file_record = self.get_file(suggestion["file_id"])
+ if suggestion["original_text"] and suggestion["original_text"] in file_record["content"]:
+ updated_content = file_record["content"].replace(suggestion["original_text"], suggestion["suggested_text"], 1)
+ else:
+ updated_content = file_record["content"] + "\n" + suggestion["suggested_text"]
+ updated_file = self.update_file(file_record["id"], updated_content)
+ with self._connect() as conn:
+ conn.execute("UPDATE suggestions SET status = 'accepted' WHERE id = ?", (suggestion_id,))
+ self.record_activity(suggestion["project_id"], suggestion["author"], "accepted_suggestion", suggestion["body"][:60])
+ return {"suggestion": self.get_suggestion(suggestion_id), "file": updated_file}
+
+ def import_reference(self, project_id: str, source: str, identifier: str) -> dict[str, Any]:
+ with self._connect() as conn:
+ row = conn.execute(
+ "SELECT * FROM references_library WHERE project_id = ? AND source = ? AND identifier = ?",
+ (project_id, source, identifier),
+ ).fetchone()
+ if row:
+ result = dict(row)
+ result["duplicate"] = True
+ return result
+
+ slug = "".join(ch for ch in identifier.lower() if ch.isalnum())[-10:] or "ref"
+ title = f"Imported {source.upper()} reference for {identifier}"
+ citation_key = f"texforge{slug}"
+ ref = {
+ "id": self._id("ref"),
+ "project_id": project_id,
+ "source": source,
+ "identifier": identifier,
+ "citation_key": citation_key,
+ "title": title,
+ "authors": "TexForge Research Team",
+ "year": str(datetime.now(UTC).year),
+ "raw_json": json.dumps({"source": source, "identifier": identifier, "title": title}),
+ "created_at": self.now(),
+ }
+ with self._connect() as conn:
+ conn.execute(
+ """INSERT INTO references_library
+ (id, project_id, source, identifier, citation_key, title, authors, year, raw_json, created_at)
+ VALUES (:id, :project_id, :source, :identifier, :citation_key, :title, :authors, :year, :raw_json, :created_at)""",
+ ref,
+ )
+ bibtex_entry = (
+ f"@article{{{citation_key},\n"
+ f" title={{{title}}},\n"
+ f" author={{{ref['authors']}}},\n"
+ f" year={{{ref['year']}}},\n"
+ f" note={{{identifier}}}\n"
+ f"}}\n"
+ )
+ refs_file = next((file for file in self.list_project_files(project_id) if file["path"] == "refs.bib"), None)
+ if refs_file and citation_key not in refs_file["content"]:
+ updated_bib = refs_file["content"].rstrip() + ("\n\n" if refs_file["content"].strip() else "") + bibtex_entry
+ self.update_file(refs_file["id"], updated_bib)
+ self.record_activity(project_id, "system", "imported_reference", identifier)
+ ref["duplicate"] = False
+ return ref
+
+ def search_references(self, project_id: str, query: str) -> list[dict[str, Any]]:
+ like = f"%{query.lower()}%"
+ with self._connect() as conn:
+ rows = conn.execute(
+ """SELECT * FROM references_library
+ WHERE project_id = ? AND (
+ lower(citation_key) LIKE ? OR lower(title) LIKE ? OR lower(authors) LIKE ? OR lower(identifier) LIKE ?
+ )
+ ORDER BY created_at DESC""",
+ (project_id, like, like, like, like),
+ ).fetchall()
+ return [dict(row) for row in rows]
+
+ def admin_metrics(self) -> dict[str, int]:
+ with self._connect() as conn:
+ users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
+ projects = conn.execute("SELECT COUNT(*) FROM projects").fetchone()[0]
+ memberships = conn.execute("SELECT COUNT(*) FROM project_memberships").fetchone()[0]
+ compile_jobs = conn.execute("SELECT COUNT(*) FROM compile_jobs").fetchone()[0]
+ references = conn.execute("SELECT COUNT(*) FROM references_library").fetchone()[0]
+ return {
+ "users": users,
+ "projects": projects,
+ "memberships": memberships,
+ "compile_jobs": compile_jobs,
+ "references": references,
+ }
+
+ def search(self, query: str) -> list[dict[str, Any]]:
+ like = f"%{query.lower()}%"
+ results: list[dict[str, Any]] = []
+ with self._connect() as conn:
+ for row in conn.execute("SELECT id, name, description FROM projects WHERE lower(name) LIKE ? OR lower(description) LIKE ?", (like, like)).fetchall():
+ results.append({"kind": "project", "id": row["id"], "label": row["name"], "snippet": row["description"]})
+ for row in conn.execute("SELECT id, path, content FROM files WHERE lower(path) LIKE ? OR lower(content) LIKE ?", (like, like)).fetchall():
+ results.append({"kind": "file", "id": row["id"], "label": row["path"], "snippet": row["content"][:120]})
+ for row in conn.execute("SELECT id, body, author FROM comments WHERE lower(body) LIKE ?", (like,)).fetchall():
+ results.append({"kind": "comment", "id": row["id"], "label": row["author"], "snippet": row["body"]})
+ for row in conn.execute("SELECT id, name, description FROM templates WHERE lower(name) LIKE ? OR lower(description) LIKE ?", (like, like)).fetchall():
+ results.append({"kind": "template", "id": row["id"], "label": row["name"], "snippet": row["description"]})
+ for row in conn.execute(
+ "SELECT id, citation_key, title FROM references_library WHERE lower(citation_key) LIKE ? OR lower(title) LIKE ? OR lower(identifier) LIKE ?",
+ (like, like, like),
+ ).fetchall():
+ results.append({"kind": "reference", "id": row["id"], "label": row["citation_key"], "snippet": row["title"]})
+ return results
+
+ def _row_to_project(self, row: sqlite3.Row) -> dict[str, Any]:
+ project = dict(row)
+ project["archived"] = bool(project["archived"])
+ project["files"] = self.list_project_files(project["id"])
+ project["comments"] = self.list_comments(project["id"])
+ project["snapshots"] = self.list_snapshots(project["id"])
+ project["branches"] = self.list_branches(project["id"])
+ project["shares"] = self.list_share_links(project["id"])
+ project["members"] = self.list_project_members(project["id"])
+ project["references"] = self.search_references(project["id"], "")
+ return project
+
+ @staticmethod
+ def _row_to_template(row: sqlite3.Row) -> dict[str, Any]:
+ template = dict(row)
+ template["tags"] = json.loads(template["tags"])
+ return template
+
+ @staticmethod
+ def _hash_password(password: str) -> str:
+ return hashlib.sha256(password.encode("utf-8")).hexdigest()
diff --git a/deliverable/texforge/texforge/services.py b/deliverable/texforge/texforge/services.py
new file mode 100644
index 000000000..ef6784186
--- /dev/null
+++ b/deliverable/texforge/texforge/services.py
@@ -0,0 +1,116 @@
+from __future__ import annotations
+
+import difflib
+import io
+import zipfile
+
+from .db import Database
+
+
+class CompileService:
+ def __init__(self, database: Database) -> None:
+ self.database = database
+
+ def compile_project(self, project_id: str, engine: str, entrypoint: str, trigger: str) -> dict:
+ project = self.database.get_project(project_id)
+ files = self.database.list_project_files(project_id)
+ entry = next((file for file in files if file["path"] == entrypoint), None)
+ if entry is None:
+ entry = files[0] if files else {"path": entrypoint, "content": ""}
+ warnings = lint_latex(entry["content"])
+ project_dir = self.database.artifact_root / project_id
+ project_dir.mkdir(parents=True, exist_ok=True)
+ job_name = f"{engine}_{entrypoint.replace('/', '_').replace('.', '_')}"
+ pdf_path = project_dir / f"{job_name}.pdf"
+ pdf_path.write_bytes(render_simple_pdf(project["name"], entry["content"]))
+ log = "\n".join(
+ [
+ f"[worker] trigger={trigger}",
+ f"[engine] {engine} {entrypoint}",
+ f"[files] {len(files)} tracked files",
+ "[status] completed simulated compile pipeline",
+ "[note] swap CompileService with Dockerized TeX Live worker for real engine execution",
+ "[lint] " + ("; ".join(warnings) if warnings else "No major issues detected"),
+ ]
+ )
+ return self.database.create_compile_job(project_id, engine, entrypoint, trigger, log, str(pdf_path))
+
+ def export_project_zip(self, project_id: str) -> bytes:
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
+ project = self.database.get_project(project_id)
+ archive.writestr("README.txt", f"Export bundle for {project['name']}\n")
+ for file in self.database.list_project_files(project_id):
+ archive.writestr(file["path"], file["content"])
+ for share in self.database.list_share_links(project_id):
+ archive.writestr("sharing/share-links.json", f"{share}\n")
+ return buffer.getvalue()
+
+
+def lint_latex(content: str) -> list[str]:
+ warnings: list[str] = []
+ if content.count("\\begin{") != content.count("\\end{"):
+ warnings.append("Environment counts appear unbalanced")
+ if "\\cite{" in content and "refs.bib" not in content:
+ warnings.append("Remember to keep bibliography entries in refs.bib")
+ if "\\ref{" in content and "\\label{" not in content:
+ warnings.append("Reference detected without nearby label definition")
+ if len(content.strip()) < 20:
+ warnings.append("Document is very short; consider expanding sections")
+ return warnings
+
+
+def render_simple_pdf(title: str, body: str) -> bytes:
+ clean_lines = [title, "TexForge preview"] + body.splitlines()[:20]
+ text = " ".join(line.replace("(", "[").replace(")", "]") for line in clean_lines)
+ stream = f"BT /F1 12 Tf 50 760 Td ({text}) Tj ET"
+ objects = [
+ b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n",
+ b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n",
+ b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n",
+ f"4 0 obj << /Length {len(stream)} >> stream\n{stream}\nendstream endobj\n".encode(),
+ b"5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n",
+ ]
+ pdf = io.BytesIO()
+ pdf.write(b"%PDF-1.4\n")
+ offsets = [0]
+ for obj in objects:
+ offsets.append(pdf.tell())
+ pdf.write(obj)
+ xref_start = pdf.tell()
+ pdf.write(f"xref\n0 {len(objects) + 1}\n".encode())
+ pdf.write(b"0000000000 65535 f \n")
+ for offset in offsets[1:]:
+ pdf.write(f"{offset:010d} 00000 n \n".encode())
+ pdf.write(f"trailer << /Size {len(objects) + 1} /Root 1 0 R >>\nstartxref\n{xref_start}\n%%EOF".encode())
+ return pdf.getvalue()
+
+
+def build_diff(source: str, target: str) -> str:
+ diff = difflib.unified_diff(source.splitlines(), target.splitlines(), fromfile="from", tofile="to", lineterm="")
+ return "\n".join(diff)
+
+
+def ai_assist(prompt: str, mode: str) -> dict[str, str]:
+ prompt_lower = prompt.lower()
+ if "table" in prompt_lower:
+ suggestion = (
+ "\\begin{table}[t]\n"
+ "\\centering\n"
+ "\\begin{tabular}{lcc}\n"
+ "Model & Accuracy & F1 \\\\ \n"
+ "\\hline\n"
+ "Baseline & 0.91 & 0.89 \\\\ \n"
+ "TexForge & 0.95 & 0.94 \\\\ \n"
+ "\\end{tabular}\n"
+ "\\caption{Experiment results.}\n"
+ "\\end{table}\n"
+ )
+ elif "citation" in prompt_lower or "doi" in prompt_lower:
+ suggestion = "@article{newref2026, title={Generated citation scaffold}, author={Author, Example}, year={2026}}"
+ elif mode == "fix":
+ suggestion = "Try adding missing \\end{...} statements, a \\bibliography section, and labels for every referenced figure or section."
+ else:
+ suggestion = "\\section{Generated Draft}\nThis paragraph was generated from your prompt and can be refined collaboratively."
+ summary = "TexForge Copilot generated a deterministic offline suggestion suitable for local demo use."
+ return {"suggestion": suggestion, "summary": summary}
diff --git a/deliverable/texforge/texforge/static/app.js b/deliverable/texforge/texforge/static/app.js
new file mode 100644
index 000000000..8bd008a1e
--- /dev/null
+++ b/deliverable/texforge/texforge/static/app.js
@@ -0,0 +1,399 @@
+const setTheme = () => {
+ const saved = localStorage.getItem('texforge-theme');
+ if (saved === 'light') document.body.classList.add('light');
+};
+setTheme();
+document.getElementById('themeToggle')?.addEventListener('click', () => {
+ document.body.classList.toggle('light');
+ localStorage.setItem('texforge-theme', document.body.classList.contains('light') ? 'light' : 'dark');
+});
+
+const page = document.body.dataset.page;
+
+if (page === 'dashboard') {
+ const form = document.getElementById('projectForm');
+ const status = document.getElementById('projectFormStatus');
+ const registerForm = document.getElementById('registerForm');
+ const loginForm = document.getElementById('loginForm');
+ const authStatus = document.getElementById('authStatus');
+ const templateSearchForm = document.getElementById('templateSearchForm');
+ const templateSearchInput = document.getElementById('templateSearchInput');
+ const templatePreview = document.getElementById('templatePreview');
+
+ const renderTemplates = (templates) => {
+ document.querySelectorAll('.template-card').forEach((card) => {
+ const visible = templates.some((tpl) => tpl.slug === card.dataset.templateSlug);
+ card.style.display = visible ? '' : 'none';
+ });
+ };
+
+ document.querySelectorAll('.template-preview-button').forEach((button) => {
+ button.addEventListener('click', async () => {
+ const response = await fetch(`/api/templates/${button.dataset.templateSlug}`);
+ const template = await response.json();
+ templatePreview.textContent = `${template.name}\n\nmain.tex\n---------\n${template.main_tex}\n\nrefs.bib\n--------\n${template.refs_bib || '(empty)'}`;
+ });
+ });
+
+ templateSearchForm?.addEventListener('submit', (event) => event.preventDefault());
+ templateSearchInput?.addEventListener('input', async () => {
+ const response = await fetch(`/api/templates?q=${encodeURIComponent(templateSearchInput.value)}`);
+ const data = await response.json();
+ renderTemplates(data.results);
+ });
+ form?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const payload = Object.fromEntries(new FormData(form).entries());
+ const response = await fetch('/api/projects', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ status.textContent = `Created ${data.name}. Redirecting...`;
+ window.location.href = `/projects/${data.id}`;
+ });
+
+ registerForm?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const payload = Object.fromEntries(new FormData(registerForm).entries());
+ const response = await fetch('/auth/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ authStatus.textContent = response.ok ? 'Registered. You can now log in.' : 'Registration failed.';
+ });
+
+ loginForm?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const payload = Object.fromEntries(new FormData(loginForm).entries());
+ const response = await fetch('/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ authStatus.textContent = response.ok ? 'Logged in. Refreshing workspace...' : 'Login failed.';
+ if (response.ok) window.location.reload();
+ });
+}
+
+if (page === 'project') {
+ const project = window.__PROJECT__;
+ const projectId = document.body.dataset.projectId;
+ const editor = document.getElementById('editor');
+ const fileList = document.getElementById('fileList');
+ const compileButton = document.getElementById('compileButton');
+ const compileLog = document.getElementById('compileLog');
+ const pdfFrame = document.getElementById('pdfFrame');
+ const highlightPanel = document.getElementById('highlightPanel');
+ const presenceStatus = document.getElementById('presenceStatus');
+ const lintStatus = document.getElementById('lintStatus');
+ const snapshotButton = document.getElementById('snapshotButton');
+ const commentForm = document.getElementById('commentForm');
+ const commentList = document.getElementById('commentList');
+ const fileForm = document.getElementById('fileForm');
+ const renameFileButton = document.getElementById('renameFileButton');
+ const deleteFileButton = document.getElementById('deleteFileButton');
+ const aiForm = document.getElementById('aiForm');
+ const aiOutput = document.getElementById('aiOutput');
+ const snapshotList = document.getElementById('snapshotList');
+ const engineSelect = document.getElementById('engineSelect');
+ const shareForm = document.getElementById('shareForm');
+ const shareStatus = document.getElementById('shareStatus');
+ const referenceForm = document.getElementById('referenceForm');
+ const referenceList = document.getElementById('referenceList');
+ const suggestionForm = document.getElementById('suggestionForm');
+ const suggestionStatus = document.getElementById('suggestionStatus');
+ const branchForm = document.getElementById('branchForm');
+ const branchList = document.getElementById('branchList');
+ const branchSnapshotSelect = document.getElementById('branchSnapshotSelect');
+ let fileItems = [...document.querySelectorAll('.file-item')];
+ let activeFile = project.files[0];
+ let debounce;
+
+ const renderHighlight = (content) => {
+ highlightPanel.textContent = content;
+ const begins = (content.match(/\\begin\{/g) || []).length;
+ const ends = (content.match(/\\end\{/g) || []).length;
+ lintStatus.textContent = begins === ends ? 'Lint: environments balanced' : 'Lint: check missing \\end{...}';
+ };
+
+ const selectFile = (item) => {
+ fileItems.forEach((node) => node.classList.remove('active'));
+ item.classList.add('active');
+ activeFile = { id: item.dataset.fileId, path: item.dataset.filePath, content: item.dataset.fileContent };
+ editor.value = activeFile.content;
+ renderHighlight(activeFile.content);
+ };
+
+ if (fileItems.length) selectFile(fileItems[0]);
+ fileItems.forEach((item) => item.addEventListener('click', () => selectFile(item)));
+
+ const attachFileItem = (li) => {
+ li.addEventListener('click', () => selectFile(li));
+ };
+
+ const refreshComments = async () => {
+ const response = await fetch(`/api/projects/${projectId}/comments`);
+ if (!response.ok) return;
+ const data = await response.json();
+ commentList.innerHTML = '';
+ data.results.forEach((comment) => {
+ const li = document.createElement('li');
+ li.innerHTML = `${comment.author} — ${comment.body} ${comment.resolved ? 'Resolved' : ''}`;
+ const toggle = document.createElement('button');
+ toggle.type = 'button';
+ toggle.className = 'ghost inline-action';
+ toggle.textContent = comment.resolved ? 'Unresolve' : 'Resolve';
+ toggle.addEventListener('click', async () => {
+ await fetch(`/api/projects/${projectId}/comments/${comment.id}/${comment.resolved ? 'unresolve' : 'resolve'}`, { method: 'POST' });
+ refreshComments();
+ });
+ li.appendChild(toggle);
+ if (comment.replies?.length) {
+ const replies = document.createElement('ul');
+ replies.className = 'mini-list nested-list';
+ comment.replies.forEach((reply) => {
+ const replyItem = document.createElement('li');
+ replyItem.innerHTML = `${reply.author} — ${reply.body}`;
+ replies.appendChild(replyItem);
+ });
+ li.appendChild(replies);
+ }
+ commentList.appendChild(li);
+ });
+ };
+
+ const addSnapshotOption = (snapshot) => {
+ const li = document.createElement('li');
+ li.dataset.snapshotId = snapshot.id;
+ li.textContent = `${snapshot.name} — ${snapshot.created_at}`;
+ snapshotList.prepend(li);
+
+ const option = document.createElement('option');
+ option.value = snapshot.id;
+ option.textContent = snapshot.name;
+ branchSnapshotSelect?.prepend(option);
+ if (branchSnapshotSelect) branchSnapshotSelect.value = snapshot.id;
+ };
+
+ const refreshBranches = async () => {
+ const response = await fetch(`/api/projects/${projectId}/branches`);
+ if (!response.ok) return;
+ const data = await response.json();
+ branchList.innerHTML = '';
+ data.results.forEach((branch) => {
+ const li = document.createElement('li');
+ li.innerHTML = `${branch.name} `;
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'ghost branch-restore-button';
+ button.textContent = 'Restore';
+ button.addEventListener('click', async () => {
+ const response = await fetch(`/api/projects/${projectId}/branches/${branch.id}/restore`, { method: 'POST' });
+ const file = await response.json();
+ if (activeFile?.id === file.id) {
+ editor.value = file.content;
+ renderHighlight(file.content);
+ }
+ });
+ li.appendChild(button);
+ branchList.appendChild(li);
+ });
+ };
+
+ const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
+ const user = `browser-${Math.random().toString(16).slice(2, 8)}`;
+ const socket = new WebSocket(`${scheme}://${window.location.host}/ws/projects/${projectId}?user=${user}`);
+ socket.addEventListener('message', (event) => {
+ const payload = JSON.parse(event.data);
+ if (payload.type === 'presence') presenceStatus.textContent = `${payload.user} ${payload.status}`;
+ if (payload.type === 'edit' && payload.path === activeFile?.path) {
+ editor.value = payload.content;
+ renderHighlight(payload.content);
+ presenceStatus.textContent = `${payload.user} updated ${payload.path}`;
+ }
+ if (payload.type === 'cursor') presenceStatus.textContent = `${payload.user} is at line ${payload.line}`;
+ if (payload.type === 'sync') presenceStatus.textContent = `Connected with ${payload.users.length} participant(s)`;
+ });
+
+ editor?.addEventListener('input', () => {
+ renderHighlight(editor.value);
+ clearTimeout(debounce);
+ debounce = setTimeout(() => {
+ socket.send(JSON.stringify({ type: 'edit', path: activeFile.path, content: editor.value }));
+ }, 200);
+ });
+
+ editor?.addEventListener('keyup', () => {
+ const line = editor.value.slice(0, editor.selectionStart).split('\n').length;
+ socket.send(JSON.stringify({ type: 'cursor', path: activeFile.path, line, column: 1 }));
+ });
+
+ compileButton?.addEventListener('click', async () => {
+ const response = await fetch(`/api/projects/${projectId}/compile`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ engine: engineSelect.value, entrypoint: activeFile?.path || 'main.tex', trigger: 'manual' }),
+ });
+ const job = await response.json();
+ compileLog.textContent = job.log;
+ pdfFrame.src = job.pdf_url;
+ });
+
+ snapshotButton?.addEventListener('click', async () => {
+ if (!activeFile?.id) return;
+ const response = await fetch(`/api/projects/${projectId}/snapshots`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ file_id: activeFile.id, name: `Snapshot ${new Date().toLocaleTimeString()}` }),
+ });
+ const snap = await response.json();
+ addSnapshotOption(snap);
+ });
+
+ commentForm?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const formData = Object.fromEntries(new FormData(commentForm).entries());
+ const response = await fetch(`/api/projects/${projectId}/comments`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...formData, file_id: activeFile.id, line_from: 1, line_to: 1 }),
+ });
+ const comment = await response.json();
+ await refreshComments();
+ commentForm.reset();
+ });
+
+ fileForm?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const payload = Object.fromEntries(new FormData(fileForm).entries());
+ const response = await fetch(`/api/projects/${projectId}/files`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const file = await response.json();
+ const li = document.createElement('li');
+ li.className = 'file-item';
+ li.dataset.fileId = file.id;
+ li.dataset.filePath = file.path;
+ li.dataset.fileContent = file.content;
+ li.textContent = file.path;
+ attachFileItem(li);
+ fileList.appendChild(li);
+ fileItems = [...document.querySelectorAll('.file-item')];
+ selectFile(li);
+ fileForm.reset();
+ });
+
+ renameFileButton?.addEventListener('click', async () => {
+ if (!activeFile?.id) return;
+ const nextPath = window.prompt('New path for this file', activeFile.path);
+ if (!nextPath || nextPath === activeFile.path) return;
+ const response = await fetch(`/api/files/${activeFile.id}/move`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path: nextPath }),
+ });
+ const file = await response.json();
+ const activeNode = document.querySelector(`.file-item[data-file-id="${file.id}"]`);
+ if (activeNode) {
+ activeNode.dataset.filePath = file.path;
+ activeNode.textContent = file.path;
+ selectFile(activeNode);
+ }
+ });
+
+ deleteFileButton?.addEventListener('click', async () => {
+ if (!activeFile?.id) return;
+ if (!window.confirm(`Delete ${activeFile.path}?`)) return;
+ const response = await fetch(`/api/files/${activeFile.id}`, { method: 'DELETE' });
+ if (!response.ok) return;
+ const activeNode = document.querySelector(`.file-item[data-file-id="${activeFile.id}"]`);
+ activeNode?.remove();
+ fileItems = [...document.querySelectorAll('.file-item')];
+ if (fileItems.length) {
+ selectFile(fileItems[0]);
+ } else {
+ activeFile = null;
+ editor.value = '';
+ }
+ });
+
+ aiForm?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const payload = Object.fromEntries(new FormData(aiForm).entries());
+ const response = await fetch(`/api/projects/${projectId}/ai/assist`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ aiOutput.textContent = `${data.summary}\n\n${data.suggestion}`;
+ });
+
+ shareForm?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const payload = Object.fromEntries(new FormData(shareForm).entries());
+ const response = await fetch(`/api/projects/${projectId}/share-links`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...payload, expires_in_days: 14 }),
+ });
+ if (!response.ok) {
+ shareStatus.textContent = 'Share link creation requires owner access.';
+ return;
+ }
+ const data = await response.json();
+ shareStatus.textContent = `Created ${data.role} link: ${data.url}`;
+ });
+
+ referenceForm?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const payload = Object.fromEntries(new FormData(referenceForm).entries());
+ const response = await fetch(`/api/projects/${projectId}/references/import`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ const li = document.createElement('li');
+ li.innerHTML = `${data.citation_key} — ${data.title}${data.duplicate ? ' (duplicate)' : ''}`;
+ referenceList.prepend(li);
+ referenceForm.reset();
+ });
+
+ suggestionForm?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const payload = Object.fromEntries(new FormData(suggestionForm).entries());
+ const createResponse = await fetch(`/api/projects/${projectId}/suggestions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...payload, file_id: activeFile.id }),
+ });
+ const suggestion = await createResponse.json();
+ const acceptResponse = await fetch(`/api/projects/${projectId}/suggestions/${suggestion.id}/accept`, { method: 'POST' });
+ const accepted = await acceptResponse.json();
+ editor.value = accepted.file.content;
+ renderHighlight(accepted.file.content);
+ suggestionStatus.textContent = `Accepted suggestion from ${suggestion.author}.\n\n${accepted.file.content}`;
+ });
+
+ branchForm?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const payload = Object.fromEntries(new FormData(branchForm).entries());
+ const response = await fetch(`/api/projects/${projectId}/branches`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) return;
+ branchForm.reset();
+ refreshBranches();
+ });
+
+ refreshComments();
+ refreshBranches();
+}
diff --git a/deliverable/texforge/texforge/static/style.css b/deliverable/texforge/texforge/static/style.css
new file mode 100644
index 000000000..df4b9e862
--- /dev/null
+++ b/deliverable/texforge/texforge/static/style.css
@@ -0,0 +1,105 @@
+:root {
+ --bg: #0b1020;
+ --panel: #151d33;
+ --muted: #8ea1c6;
+ --text: #eef3ff;
+ --accent: #7c9cff;
+ --border: rgba(255,255,255,0.08);
+}
+body.light {
+ --bg: #f2f5ff;
+ --panel: #ffffff;
+ --muted: #5c6d93;
+ --text: #102040;
+ --accent: #365ef6;
+ --border: rgba(16,32,64,0.1);
+}
+* { box-sizing: border-box; }
+body {
+ margin: 0;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
+ background: radial-gradient(circle at top, rgba(124,156,255,0.18), transparent 35%), var(--bg);
+ color: var(--text);
+}
+a { color: inherit; text-decoration: none; }
+button, input, textarea, select {
+ font: inherit;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background: rgba(255,255,255,0.03);
+ color: var(--text);
+ padding: 0.8rem 1rem;
+}
+textarea { min-height: 120px; resize: vertical; }
+button { cursor: pointer; }
+.topbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 2rem;
+ padding: 2rem;
+}
+.topbar.compact { padding-bottom: 1rem; }
+.topbar-actions { display: flex; gap: 0.75rem; align-items: center; }
+.eyebrow { text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); font-size: 0.78rem; }
+.lede { max-width: 70ch; color: var(--muted); }
+.dashboard-grid, .workspace-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 1rem;
+ padding: 0 2rem 2rem;
+}
+.panel {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 24px;
+ padding: 1.25rem;
+ box-shadow: 0 20px 50px rgba(0,0,0,0.16);
+}
+.panel-header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
+.hero-card { min-height: 220px; display: flex; flex-direction: column; justify-content: center; }
+.span-2 { grid-column: span 2; }
+.primary, button.primary {
+ background: linear-gradient(135deg, var(--accent), #9f7cff);
+ color: white;
+ border: none;
+ padding: 0.85rem 1.15rem;
+ border-radius: 14px;
+}
+.ghost, button.ghost { background: transparent; }
+.badge, .tag {
+ display: inline-flex;
+ align-items: center;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 0.3rem 0.65rem;
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+.project-grid, .template-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
+.auth-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
+.project-card, .template-card { border: 1px solid var(--border); border-radius: 18px; padding: 1rem; }
+.project-meta, .tag-row, .toolbar { display: flex; gap: 0.5rem; flex-wrap: wrap; }
+.compact-toolbar { margin-top: 0.75rem; }
+.mini-list, .timeline, .file-list { list-style: none; padding: 0; margin: 0; }
+.mini-list li, .timeline li { padding: 0.6rem 0; border-bottom: 1px solid var(--border); }
+.stack-form { display: grid; gap: 0.75rem; }
+.compact-form { margin-top: 1rem; }
+.file-browser { max-height: 84vh; overflow: auto; }
+.file-item { padding: 0.75rem; border-radius: 12px; border: 1px solid transparent; cursor: pointer; }
+.file-item.active, .file-item:hover { border-color: var(--accent); background: rgba(124,156,255,0.12); }
+.split-view { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 1rem; }
+.editor { width: 100%; min-height: 52vh; background: #0c1326; color: #f4f7ff; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+body.light .editor { background: #f8fbff; color: #102040; }
+.editor-footer { display: flex; justify-content: space-between; gap: 1rem; margin-top: 0.5rem; }
+.pdf-frame, .highlight-panel, .log-panel { width: 100%; min-height: 250px; border-radius: 18px; border: 1px solid var(--border); background: rgba(255,255,255,0.02); padding: 1rem; }
+.highlight-panel { min-height: 180px; white-space: pre-wrap; overflow: auto; }
+.log-panel { white-space: pre-wrap; overflow: auto; }
+.muted { color: var(--muted); }
+.inline-action { margin-left: 0.75rem; padding: 0.35rem 0.7rem; border-radius: 999px; }
+.nested-list { margin-top: 0.5rem; margin-left: 1rem; }
+@media (max-width: 1100px) {
+ .dashboard-grid, .workspace-grid, .split-view { grid-template-columns: 1fr; }
+ .span-2 { grid-column: span 1; }
+ .topbar { flex-direction: column; }
+}
diff --git a/deliverable/texforge/texforge/templates/dashboard.html b/deliverable/texforge/texforge/templates/dashboard.html
new file mode 100644
index 000000000..cd8d0f27b
--- /dev/null
+++ b/deliverable/texforge/texforge/templates/dashboard.html
@@ -0,0 +1,158 @@
+
+
+
+
+
+ TexForge
+
+
+
+
+
+
Collaborative academic writing platform
+
TexForge
+
Production-style collaborative LaTeX workspace with live collaboration, history, compile queue simulation, sharing, and AI-assisted authoring.
+
+
+ {% if current_user %}
+ Signed in as {{ current_user.name }}
+ {% else %}
+ Guest demo mode
+ {% endif %}
+
+ New Project
+
+
+
+
+
+
Live collaboration
+
Cursor presence, awareness, shared editing, compile logs, comments, snapshots, templates, organizations, and admin insights are all available in one local-first stack.