This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: rich edit paradigm for strata page

Inline editing on all analysis fields (title, synthesis, themes,
tensions, questions). Theme tags are editable chips with add/remove.
Regenerate button re-runs analysis with diff highlighting (purple glow
on changed fields). Save to PDS button writes updated record via
com.atproto.repo.putRecord. Editable = human, generatable = machine,
symbiotic loop.

Also adds PUT /xrpc/org.latha.strata.updateRecord endpoint and
PDS_APP_PASSWORD worker secret for PDS writes.

๐Ÿ‘พ Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>

author
nandi
co-author
Letta Code
date (May 14, 2026, 4:50 AM UTC) commit 9b0a4f22 parent 9e4b49a0
+388 -78
+229 -78
src/frontend/app.ts
··· 479 479 const target = e.currentTarget as HTMLElement; 480 480 const islandId = target.dataset.islandId; 481 481 if (!islandId) return; 482 - window.location.href = `/strata?id=${encodeURIComponent(islandId)}`; 482 + const island = islands.find(i => i.id === islandId); 483 + window.location.href = `/strata?id=${encodeURIComponent(island?.recordUri || islandId)}`; 483 484 }); 484 485 }); 485 486 } ··· 613 614 } 614 615 615 616 document.getElementById("island-strata-btn")?.addEventListener("click", async () => { 616 - window.location.href = `/strata?id=${encodeURIComponent(islandId)}`; 617 + window.location.href = `/strata?id=${encodeURIComponent(island.recordUri || islandId)}`; 617 618 }); 618 619 } 619 620 ··· 675 676 676 677 const summary = island.summary || summarizeIsland(island); 677 678 678 - // Themes 679 - const themesHtml = s.themes.length > 0 680 - ? `<div class="theme-tags">${s.themes.map(t => `<span class="theme-tag">${escHtml(t)}</span>`).join("")}</div>` 681 - : ""; 682 - 683 - // Relationships โ€” link entity names to their Semble pages 684 - const titleToUri = new Map<string, string>(); 685 - for (const uri of island.vertices!) { 686 - const m = island.vertexMeta![uri]; 687 - const title = m?.title; 688 - if (title && title.length > 3 && !title.startsWith("http") && !title.startsWith("at://")) { 689 - titleToUri.set(title, uri); 690 - } 691 - } 692 - 693 - const relsHtml = s.relationships.length > 0 694 - ? `<div class="strata-relationships">${s.relationships.map(r => { 695 - // Link entity names to their Semble pages 696 - let styled = escHtml(r); 697 - // Sort titles longest-first so longer matches take priority 698 - const sortedTitles = [...titleToUri.entries()].sort((a, b) => b[0].length - a[0].length); 699 - for (const [title, uri] of sortedTitles) { 700 - const escaped = escHtml(title); 701 - // Exact match 702 - if (styled.includes(escaped)) { 703 - const href = `https://semble.so/url/${encodeURIComponent(uri)}`; 704 - styled = styled.replace(escaped, `<a href="${href}" target="_blank" rel="noopener" class="entity-link">${escaped}</a>`); 705 - } 706 - } 707 - // Also try linking short names that appear as substrings of titles 708 - // e.g. "C2PA" appears inside "C2PA content authenticity standard" 709 - for (const [title, uri] of sortedTitles) { 710 - const href = `https://semble.so/url/${encodeURIComponent(uri)}`; 711 - // Extract short name segments from the title (first N words, abbreviations, etc.) 712 - const shortNames = [ 713 - title.split(/[:โ€”โ€“\-]/)[0].trim(), // text before delimiter 714 - title.split("(")[0].trim(), // text before parenthetical 715 - ].filter(n => n.length > 3 && n.length < title.length); 716 - for (const shortName of shortNames) { 717 - const escaped = escHtml(shortName); 718 - if (styled.includes(escaped) && !styled.includes(`>${escaped}<`)) { 719 - // Not already linked โ€” link it 720 - styled = styled.replace(escaped, `<a href="${href}" target="_blank" rel="noopener" class="entity-link">${escaped}</a>`); 721 - } 722 - } 723 - } 724 - styled = styled 725 - .replace(/โ†’/g, '<span class="arrow arrow-forward">โ†’</span>') 726 - .replace(/โ†/g, '<span class="arrow arrow-back">โ†</span>') 727 - .replace(/โ†”/g, '<span class="arrow arrow-bidi">โ†”</span>') 728 - .replace(/โ‡/g, '<span class="arrow arrow-blocked">โ‡</span>'); 729 - return `<div class="strata-rel-line">${styled}</div>`; 730 - }).join("")}</div>` 731 - : ""; 732 - 733 - // Tensions 734 - const tensionsHtml = s.tensions.length > 0 735 - ? `<div class="strata-section"><h3>Tensions</h3>${s.tensions.map(t => `<div class="analysis-tension">${escHtml(t)}</div>`).join("")}</div>` 736 - : ""; 737 - 738 - // Open questions 739 - const questionsHtml = s.open_questions.length > 0 740 - ? `<div class="strata-section"><h3>Open Questions</h3>${s.open_questions.map(q => `<div class="analysis-question">${escHtml(q)}</div>`).join("")}</div>` 741 - : ""; 742 - 743 - // Synthesis 744 - const synthesisHtml = s.synthesis 745 - ? `<div class="strata-section"><h3>Synthesis</h3><div class="synthesis-text">${escHtml(s.synthesis)}</div></div>` 746 - : ""; 747 - 679 + // Build editable strata page 748 680 content.innerHTML = ` 749 681 <div class="strata-page"> 750 682 <a href="/" class="back-link">&larr; Explore</a> 751 - <p class="island-summary-large">${escHtml(summary)}</p> 683 + <p class="island-summary-large" data-field="title" contenteditable="true">${escHtml(summary)}</p> 752 684 <p class="island-meta">${island.vertices!.length} vertices &middot; ${island.edges!.length} edges${island.recordUri ? ` &middot; <a href="https://pdsls.dev/at://did:plc:ngokl2gnmpbvuvrfckja3g7p/org.latha.strata/${island.id}#record" target="_blank" rel="noopener" class="pds-link">View on PDS &nearr;</a>` : ""}</p> 753 685 754 686 <canvas class="island-graph-large" id="strata-graph"></canvas> 755 687 756 - <div class="strata-section"><h3>Synthesis</h3><div class="synthesis-text">${escHtml(s.synthesis)}</div></div> 688 + <div class="strata-section"> 689 + <h3>Synthesis <span class="edit-hint" title="Click to edit">&#9998;</span></h3> 690 + <div class="synthesis-text editable" data-field="synthesis" contenteditable="true">${escHtml(s.synthesis)}</div> 691 + </div> 757 692 758 - ${themesHtml ? `<div class="strata-section"><h3>Themes</h3>${themesHtml}</div>` : ""} 693 + <div class="strata-section"> 694 + <h3>Themes <span class="edit-hint" title="Click to edit">&#9998;</span></h3> 695 + <div class="theme-tags editable" data-field="themes"> 696 + ${s.themes.map(t => `<span class="theme-tag" data-value="${escHtml(t)}">${escHtml(t)}<span class="theme-remove">&times;</span></span>`).join("")} 697 + <span class="theme-add">+</span> 698 + </div> 699 + </div> 700 + 701 + <div class="strata-section"> 702 + <h3>Tensions <span class="edit-hint" title="Click to edit">&#9998;</span></h3> 703 + <div class="editable-list" data-field="tensions"> 704 + ${s.tensions.map(t => `<div class="analysis-tension editable" contenteditable="true">${escHtml(t)}</div>`).join("")} 705 + <div class="list-add" data-field="tensions">+ Add tension</div> 706 + </div> 707 + </div> 759 708 760 - ${relsHtml ? `<div class="strata-section"><h3>Relationships</h3>${relsHtml}</div>` : ""} 709 + <div class="strata-section"> 710 + <h3>Open Questions <span class="edit-hint" title="Click to edit">&#9998;</span></h3> 711 + <div class="editable-list" data-field="openQuestions"> 712 + ${s.open_questions.map(q => `<div class="analysis-question editable" contenteditable="true">${escHtml(q)}</div>`).join("")} 713 + <div class="list-add" data-field="openQuestions">+ Add question</div> 714 + </div> 715 + </div> 761 716 762 - ${tensionsHtml} 763 - ${questionsHtml} 717 + <div class="strata-actions"> 718 + <button class="strata-btn regenerate-btn" id="regenerate-btn">Regenerate</button> 719 + <button class="strata-btn save-btn hidden" id="save-btn">Save to PDS</button> 720 + <span class="save-status" id="save-status"></span> 721 + </div> 764 722 765 723 <div class="strata-section"> 766 724 <h3>Edges</h3> ··· 789 747 if (canvas) { 790 748 new ForceGraph(canvas, island); 791 749 } 750 + 751 + // โ”€โ”€ Edit tracking โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 752 + let dirty = false; 753 + const saveBtn = document.getElementById("save-btn"); 754 + const saveStatus = document.getElementById("save-status"); 755 + 756 + function markDirty() { 757 + dirty = true; 758 + saveBtn?.classList.remove("hidden"); 759 + saveStatus!.textContent = ""; 760 + } 761 + 762 + // Track contenteditable changes 763 + const editContainer = content!; 764 + editContainer.querySelectorAll("[contenteditable]").forEach(el => { 765 + el.addEventListener("input", markDirty); 766 + }); 767 + 768 + // Theme tag removal 769 + editContainer.querySelectorAll(".theme-remove").forEach(btn => { 770 + btn.addEventListener("click", (e) => { 771 + (e.currentTarget as HTMLElement).parentElement!.remove(); 772 + markDirty(); 773 + }); 774 + }); 775 + 776 + // Theme tag add 777 + editContainer.querySelectorAll(".theme-add").forEach(btn => { 778 + btn.addEventListener("click", () => { 779 + const tag = document.createElement("span"); 780 + tag.className = "theme-tag"; 781 + tag.dataset.value = "new theme"; 782 + tag.innerHTML = 'new theme<span class="theme-remove">&times;</span>'; 783 + tag.setAttribute("contenteditable", "true"); 784 + tag.addEventListener("input", () => { tag.dataset.value = tag.textContent?.replace("ร—", "").trim() || ""; markDirty(); }); 785 + tag.querySelector(".theme-remove")!.addEventListener("click", () => { tag.remove(); markDirty(); }); 786 + (btn as Element as ChildNode).before(tag); 787 + tag.focus(); 788 + markDirty(); 789 + }); 790 + }); 791 + 792 + // List item add 793 + editContainer.querySelectorAll(".list-add").forEach(btn => { 794 + btn.addEventListener("click", () => { 795 + const field = (btn as HTMLElement).dataset.field!; 796 + const div = document.createElement("div"); 797 + div.className = field === "tensions" ? "analysis-tension editable" : "analysis-question editable"; 798 + div.setAttribute("contenteditable", "true"); 799 + div.textContent = "New item..."; 800 + div.addEventListener("input", markDirty); 801 + (btn as Element as ChildNode).before(div); 802 + div.focus(); 803 + markDirty(); 804 + }); 805 + }); 806 + 807 + // โ”€โ”€ Collect current analysis state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 808 + function collectAnalysis(): Record<string, any> { 809 + const titleEl = editContainer.querySelector('[data-field="title"]'); 810 + const synthesisEl = editContainer.querySelector('[data-field="synthesis"]'); 811 + const themeTags = editContainer.querySelectorAll('.theme-tag'); 812 + const tensionEls = editContainer.querySelectorAll('[data-field="tensions"] .editable'); 813 + const questionEls = editContainer.querySelectorAll('[data-field="openQuestions"] .editable'); 814 + 815 + return { 816 + title: titleEl?.textContent?.trim() || "", 817 + synthesis: synthesisEl?.textContent?.trim() || "", 818 + themes: Array.from(themeTags).map(t => t.getAttribute("data-value") || t.textContent?.replace("ร—", "").trim() || ""), 819 + tensions: Array.from(tensionEls).map(t => t.textContent?.trim() || "").filter(Boolean), 820 + openQuestions: Array.from(questionEls).map(q => q.textContent?.trim() || "").filter(Boolean), 821 + }; 822 + } 823 + 824 + // โ”€โ”€ Save to PDS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 825 + saveBtn?.addEventListener("click", async () => { 826 + if (!island.recordUri) return; 827 + saveBtn.textContent = "Saving..."; 828 + saveBtn.setAttribute("disabled", "true"); 829 + 830 + try { 831 + const analysis = collectAnalysis(); 832 + const res = await fetch("/xrpc/org.latha.strata.updateRecord", { 833 + method: "PUT", 834 + headers: { "Content-Type": "application/json" }, 835 + body: JSON.stringify({ uri: island.recordUri, analysis }), 836 + }); 837 + if (!res.ok) { 838 + const err = (await res.json()) as { message?: string }; 839 + throw new Error(err.message || "Save failed"); 840 + } 841 + dirty = false; 842 + saveBtn.classList.add("hidden"); 843 + saveStatus!.textContent = "Saved โœ“"; 844 + setTimeout(() => { saveStatus!.textContent = ""; }, 3000); 845 + } catch (e: any) { 846 + saveStatus!.textContent = `Error: ${e.message}`; 847 + } finally { 848 + saveBtn.textContent = "Save to PDS"; 849 + saveBtn.removeAttribute("disabled"); 850 + } 851 + }); 852 + 853 + // โ”€โ”€ Regenerate โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 854 + const regenBtn = document.getElementById("regenerate-btn"); 855 + regenBtn?.addEventListener("click", async () => { 856 + regenBtn.textContent = "Generating..."; 857 + regenBtn.setAttribute("disabled", "true"); 858 + 859 + try { 860 + const res = await fetch("/xrpc/org.latha.strata.analyze", { 861 + method: "POST", 862 + headers: { "Content-Type": "application/json" }, 863 + body: JSON.stringify({ island: { vertices: island.vertices, edges: island.edges } }), 864 + }); 865 + if (!res.ok) throw new Error("Analysis failed"); 866 + const result = await res.json(); 867 + 868 + // Diff-highlight: mark changed fields 869 + const oldAnalysis = collectAnalysis(); 870 + const newAnalysis = result as Record<string, any>; 871 + 872 + // Update fields with animation 873 + const titleEl = editContainer.querySelector('[data-field="title"]') as HTMLElement; 874 + const synthesisEl = editContainer.querySelector('[data-field="synthesis"]') as HTMLElement; 875 + 876 + if (titleEl && newAnalysis.title !== oldAnalysis.title) { 877 + titleEl.textContent = newAnalysis.title; 878 + titleEl.classList.add("field-changed"); 879 + setTimeout(() => titleEl.classList.remove("field-changed"), 2000); 880 + } 881 + if (synthesisEl && newAnalysis.synthesis !== oldAnalysis.synthesis) { 882 + synthesisEl.textContent = newAnalysis.synthesis; 883 + synthesisEl.classList.add("field-changed"); 884 + setTimeout(() => synthesisEl.classList.remove("field-changed"), 2000); 885 + } 886 + 887 + // Update themes 888 + const themesContainer = editContainer.querySelector('[data-field="themes"]'); 889 + if (themesContainer) { 890 + themesContainer.querySelectorAll(".theme-tag").forEach(t => t.remove()); 891 + const addBtn = themesContainer.querySelector(".theme-add"); 892 + for (const theme of newAnalysis.themes || []) { 893 + const tag = document.createElement("span"); 894 + tag.className = "theme-tag field-changed"; 895 + tag.dataset.value = theme; 896 + tag.innerHTML = `${escHtml(theme)}<span class="theme-remove">&times;</span>`; 897 + tag.querySelector(".theme-remove")!.addEventListener("click", () => { tag.remove(); markDirty(); }); 898 + (addBtn as ChildNode)?.before(tag); 899 + setTimeout(() => tag.classList.remove("field-changed"), 2000); 900 + } 901 + } 902 + 903 + // Update tensions 904 + const tensionsContainer = editContainer.querySelector('[data-field="tensions"]'); 905 + if (tensionsContainer) { 906 + tensionsContainer.querySelectorAll(".editable").forEach(t => t.remove()); 907 + const addBtn = tensionsContainer.querySelector(".list-add"); 908 + for (const t of newAnalysis.tensions || []) { 909 + const div = document.createElement("div"); 910 + div.className = "analysis-tension editable field-changed"; 911 + div.setAttribute("contenteditable", "true"); 912 + div.textContent = t; 913 + div.addEventListener("input", markDirty); 914 + (addBtn as ChildNode)?.before(div); 915 + setTimeout(() => div.classList.remove("field-changed"), 2000); 916 + } 917 + } 918 + 919 + // Update questions 920 + const questionsContainer = editContainer.querySelector('[data-field="openQuestions"]'); 921 + if (questionsContainer) { 922 + questionsContainer.querySelectorAll(".editable").forEach(q => q.remove()); 923 + const addBtn = questionsContainer.querySelector(".list-add"); 924 + for (const q of newAnalysis.openQuestions || []) { 925 + const div = document.createElement("div"); 926 + div.className = "analysis-question editable field-changed"; 927 + div.setAttribute("contenteditable", "true"); 928 + div.textContent = q; 929 + div.addEventListener("input", markDirty); 930 + (addBtn as ChildNode)?.before(div); 931 + setTimeout(() => div.classList.remove("field-changed"), 2000); 932 + } 933 + } 934 + 935 + markDirty(); // Regenerated = needs save 936 + } catch (e: any) { 937 + saveStatus!.textContent = `Regenerate error: ${e.message}`; 938 + } finally { 939 + regenBtn.textContent = "Regenerate"; 940 + regenBtn.removeAttribute("disabled"); 941 + } 942 + }); 792 943 } 793 944 794 945 // โ”€โ”€ Connect page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+80
src/landing-page.ts
··· 1063 1063 background: var(--border); 1064 1064 color: var(--text); 1065 1065 font-size: 0.75rem; 1066 + display: inline-flex; 1067 + align-items: center; 1068 + gap: 0.25rem; 1066 1069 } 1070 + .theme-tag[contenteditable] { cursor: text; outline: none; } 1071 + .theme-tag[contenteditable]:focus { background: var(--accent); color: #fff; } 1072 + .theme-remove { 1073 + cursor: pointer; 1074 + opacity: 0.4; 1075 + font-size: 0.875rem; 1076 + line-height: 1; 1077 + } 1078 + .theme-remove:hover { opacity: 1; color: #f85149; } 1079 + .theme-add { 1080 + display: inline-flex; 1081 + align-items: center; 1082 + justify-content: center; 1083 + width: 1.5rem; 1084 + height: 1.5rem; 1085 + border-radius: 12px; 1086 + background: var(--border); 1087 + color: var(--text-muted); 1088 + font-size: 0.875rem; 1089 + cursor: pointer; 1090 + transition: all 0.15s; 1091 + } 1092 + .theme-add:hover { background: var(--accent); color: #fff; } 1093 + 1094 + /* Editable fields */ 1095 + .editable { cursor: text; border-radius: 4px; transition: outline 0.15s; outline: 1px solid transparent; } 1096 + .editable:focus { outline: 1px solid var(--accent); background: rgba(88,166,255,0.05); } 1097 + .edit-hint { opacity: 0; font-size: 0.75rem; color: var(--text-muted); cursor: pointer; transition: opacity 0.15s; } 1098 + .strata-section:hover .edit-hint { opacity: 1; } 1099 + .editable-list .list-add { 1100 + color: var(--text-muted); 1101 + font-size: 0.8rem; 1102 + cursor: pointer; 1103 + padding: 0.25rem 0; 1104 + opacity: 0.6; 1105 + transition: opacity 0.15s; 1106 + } 1107 + .editable-list .list-add:hover { opacity: 1; } 1108 + 1109 + /* Diff highlight โ€” brief glow on regenerated fields */ 1110 + .field-changed { 1111 + animation: field-glow 2s ease-out; 1112 + } 1113 + @keyframes field-glow { 1114 + 0% { background: rgba(188,140,255,0.3); } 1115 + 100% { background: transparent; } 1116 + } 1117 + 1118 + /* Strata action bar */ 1119 + .strata-actions { 1120 + display: flex; 1121 + align-items: center; 1122 + gap: 0.75rem; 1123 + margin: 1.5rem 0; 1124 + padding-top: 1rem; 1125 + border-top: 1px solid var(--border); 1126 + } 1127 + .regenerate-btn { 1128 + background: var(--accent); 1129 + border-color: var(--accent); 1130 + color: #fff; 1131 + font-size: 0.75rem; 1132 + padding: 0.375rem 1rem; 1133 + } 1134 + .regenerate-btn:hover { background: #79c0ff; border-color: #79c0ff; } 1135 + .regenerate-btn:disabled { opacity: 0.5; cursor: wait; } 1136 + .save-btn { 1137 + background: #238636; 1138 + border-color: #238636; 1139 + color: #fff; 1140 + font-size: 0.75rem; 1141 + padding: 0.375rem 1rem; 1142 + } 1143 + .save-btn:hover { background: #2ea043; border-color: #2ea043; } 1144 + .save-btn:disabled { opacity: 0.5; cursor: wait; } 1145 + .save-status { font-size: 0.8rem; color: var(--text-muted); } 1146 + .hidden { display: none !important; } 1067 1147 </style> 1068 1148 </head> 1069 1149 <body>
+79
src/worker.ts
··· 14 14 VAULT_BUCKET: R2Bucket; 15 15 CARRY_BUCKET: R2Bucket; 16 16 STRATA_CONTAINER: DurableObjectNamespace; 17 + PDS_APP_PASSWORD: string; 17 18 } 18 19 19 20 // โ”€โ”€โ”€ Contrail setup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ··· 751 752 } catch (e: any) { 752 753 return c.json({ error: "ContainerError", message: e.message }, 500); 753 754 } 755 + }); 756 + 757 + // โ”€โ”€ Update strata record (write back to PDS) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 758 + app.put("/xrpc/org.latha.strata.updateRecord", async (c) => { 759 + const body = await c.req.json().catch(() => ({})) as { 760 + uri?: string; 761 + analysis?: Record<string, any>; 762 + }; 763 + if (!body.uri || !body.analysis) { 764 + return c.json({ error: "MissingRequiredField", message: "uri and analysis are required" }, 400); 765 + } 766 + 767 + const appPassword = env.PDS_APP_PASSWORD; 768 + if (!appPassword) { 769 + return c.json({ error: "NotConfigured", message: "PDS_APP_PASSWORD not set" }, 500); 770 + } 771 + 772 + // Parse the AT URI to extract repo DID, collection, rkey 773 + const parts = body.uri.replace("at://", "").split("/"); 774 + const repoDid = parts[0]; 775 + const collection = parts[1]; 776 + const rkey = parts[2]; 777 + if (!repoDid || !collection || !rkey) { 778 + return c.json({ error: "InvalidUri", message: "Expected at://did:.../org.latha.strata/rkey" }, 400); 779 + } 780 + 781 + // Read existing record from D1 782 + const row = await db 783 + .prepare("SELECT record FROM records_strata WHERE uri = ?") 784 + .bind(body.uri) 785 + .first<{ record: string }>(); 786 + if (!row) { 787 + return c.json({ error: "RecordNotFound" }, 404); 788 + } 789 + 790 + // Merge updated analysis into existing record 791 + const record = JSON.parse(row.record); 792 + record.analysis = { ...record.analysis, ...body.analysis }; 793 + record.updatedAt = new Date().toISOString(); 794 + 795 + // Write back to PDS via com.atproto.repo.putRecord 796 + const pdsHost = "amanita.us-east.host.bsky.network"; 797 + const authResp = await fetch(`https://${pdsHost}/xrpc/com.atproto.server.createSession`, { 798 + method: "POST", 799 + headers: { "Content-Type": "application/json" }, 800 + body: JSON.stringify({ identifier: repoDid, password: appPassword }), 801 + }); 802 + if (!authResp.ok) { 803 + const err = await authResp.text(); 804 + return c.json({ error: "AuthFailed", message: err }, 500); 805 + } 806 + const { accessJwt } = await authResp.json() as { accessJwt: string }; 807 + 808 + const putResp = await fetch(`https://${pdsHost}/xrpc/com.atproto.repo.putRecord`, { 809 + method: "POST", 810 + headers: { 811 + "Content-Type": "application/json", 812 + "Authorization": `Bearer ${accessJwt}`, 813 + }, 814 + body: JSON.stringify({ 815 + repo: repoDid, 816 + collection, 817 + rkey, 818 + record, 819 + }), 820 + }); 821 + if (!putResp.ok) { 822 + const err = await putResp.text(); 823 + return c.json({ error: "PutRecordFailed", message: err }, 500); 824 + } 825 + 826 + // Update D1 cache 827 + await db 828 + .prepare("UPDATE records_strata SET record = ? WHERE uri = ?") 829 + .bind(JSON.stringify(record), body.uri) 830 + .run(); 831 + 832 + return c.json({ ok: true, uri: body.uri }); 754 833 }); 755 834 756 835 // โ”€โ”€ R2 sync endpoints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€