From 7eb5ad185ece382a7a80a6c064e61415cb46ded9 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 23 Sep 2026 14:05:09 -0700 Subject: [PATCH] =?UTF-8?q?aesel:=20the=20agreement=20is=20one=20sentence?= =?UTF-8?q?=20under=20the=20donkey=20=E2=80=94=20agree,=20or=20quit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What is shared, with whom, for how long, and where the words you type go, in one sentence you can read before pressing a key. The donkey moves as one picture over it. Disclosure version 5, so everyone reads the new sentence once; 0.7.18 so installed copies fetch it. --- easel/package.json | 2 +- easel/src/required-sharing.mjs | 24 +++++++++++++++--------- easel/test/required-sharing.test.mjs | 26 ++++++++++++++------------ 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/easel/package.json b/easel/package.json index 37dcfa047..9b8b6a69a 100644 --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.7.17", + "version": "0.7.18", "private": true, "type": "module", "scripts": { diff --git a/easel/src/required-sharing.mjs b/easel/src/required-sharing.mjs index 0cbbd89ce..319eddf8d 100644 --- a/easel/src/required-sharing.mjs +++ b/easel/src/required-sharing.mjs @@ -1,11 +1,14 @@ import {createHash} from 'node:crypto'; import {mkdir,readFile,writeFile,rename,lstat} from 'node:fs/promises'; import {join} from 'node:path'; -import {MASCOT_SETTLED_MS,mascotRow,mascotRows} from './mascot.mjs'; +import {MASCOT_SETTLED_MS,MASCOT_WIDTH,mascotRow,mascotRows} from './mascot.mjs'; const MASCOT_ROW_MARK=mascotRow(0,false); import {color,textWidth,wrapText} from './render.mjs'; -export const DISCLOSURE_VERSION=4; -export const TRANSCRIPT_DISCLOSURE='aesel requires sharing your messages, assistant replies, and artifact revision references with Aesthetic Computer to improve aesel, including when you bring your own Codex or Claude account. Uploaded transcripts are accessible only to authorized AC staff and retained indefinitely until you delete them. Recognizable credentials are redacted, but messages may contain personal information. For AI generation, prompts, conversation context, source code and tool results are sent to your selected provider. Hosted text uses OpenRouter and the selected model provider; hosted images use OpenAI and include reference images you supply. Provider privacy policies apply. Earlier private conversations are not uploaded. You can export or delete uploaded transcripts. If you do not agree, quit without starting a session.'; +export const DISCLOSURE_VERSION=5; +// One sentence, because that is how long a thing you are asked to agree to +// should be. It says what is shared, with whom, for how long, and where the +// words you type go. The keys under it are the whole choice: agree, or quit. +export const TRANSCRIPT_DISCLOSURE='By continuing you agree that aesel shares your messages, the assistant\'s replies and artifact references with Aesthetic Computer to improve aesel, keeps them until you delete them, and sends what you write to the AI provider you choose under that provider\'s privacy policy.'; const account=session=>session.read()?.user?.sub; // The first thing a new person sees. It used to be a paragraph in the corner // of a black window; now the donkey stands over it in the middle, painted the @@ -20,22 +23,22 @@ export function sharingScreen({columns=80,rows=24,useColor=true,signedIn=false}= const plain=text.replace(/\s+$/,''); // The head is the mark, the body is the soft purple, and the star on the // canvas is the one orange thing on the screen. - return {plain,painted:useColor?plain.split('*').map(part=>ink(tone,part)).join(ink('highlight','*')):plain}; + return {plain,donkey:true,painted:useColor?plain.split('*').map(part=>ink(tone,part)).join(ink('highlight','*')):plain}; }); const paragraph=wrapText(TRANSCRIPT_DISCLOSURE,Math.min(DISCLOSURE_COLUMNS,width-4)); - const keys=`[A] Agree and continue${signedIn?'':' (sign in)'} [Q] Quit`; + const keys='[A] Agree [Q] Quit'; const gap={plain:'',painted:''}; - const title=(mark)=>({plain:`${mark}Aesel data sharing`,painted:`${mark?ink('handle',mark):''}${ink('highlight','Aesel data sharing')}`}); + const title=(mark)=>({plain:`${mark}aesel`,painted:`${mark?ink('handle',mark):''}${ink('highlight','aesel')}`}); // One colour span for the whole paragraph, opened on its first line and // closed on its last: the words between two lines stay plain text with plain // whitespace, so anything reading the screen for a phrase still finds it. const soft=useColor?color.soft||'':''; const tail=[gap,...paragraph.map((line,i)=>({plain:line,painted:`${i===0?soft:''}${line}${i===paragraph.length-1&&soft?color.reset:''}`})),gap, - {plain:keys,painted:`${ink('handle','[A]')} Agree and continue${signedIn?'':' (sign in)'} ${ink('handle','[Q]')} Quit`}]; + {plain:keys,painted:`${ink('handle','[A]')} Agree ${ink('handle','[Q]')} Quit`}]; // The whole donkey when the window has the rows for him; his one-row self // beside the title when it does not, so an 80×24 terminal still shows the // words whole and the keys on screen. - const full=[...donkey,gap,title(''),...tail]; + const full=[...donkey,...tail]; const block=full.length<=height?full:[title(`${MASCOT_ROW_MARK} `),...tail]; const centred=new Set(paragraph); const blockWidth=Math.max(...block.map(row=>textWidth(row.plain))); @@ -43,7 +46,10 @@ export function sharingScreen({columns=80,rows=24,useColor=true,signedIn=false}= const top=Math.max(0,Math.floor((height-block.length)/2)); // The paragraph keeps a straight left edge; the donkey, the title and the // keys stand over its middle. - const indent=row=>centred.has(row.plain)||!row.plain?0:Math.floor((blockWidth-textWidth(row.plain))/2); + // The donkey is one picture: every row of him moves by the same amount, or + // he comes apart. The keys stand over the middle on their own. + const donkeyIndent=Math.max(0,Math.floor((blockWidth-MASCOT_WIDTH)/2)); + const indent=row=>row.donkey?donkeyIndent:centred.has(row.plain)||!row.plain?0:Math.floor((blockWidth-textWidth(row.plain))/2); return '\r\n'.repeat(top)+block.map(row=>' '.repeat(left+indent(row))+row.painted).join('\r\n')+'\r\n'; } export async function readAcknowledgment(root,owner){ diff --git a/easel/test/required-sharing.test.mjs b/easel/test/required-sharing.test.mjs index 1abdf485b..c15bfcc09 100644 --- a/easel/test/required-sharing.test.mjs +++ b/easel/test/required-sharing.test.mjs @@ -3,19 +3,19 @@ import {createHash} from 'node:crypto'; import {requireSharing,readAcknowledgment,DISCLOSURE_VERSION} from '../src/required-sharing.mjs'; async function fixture(t){const root=await mkdtemp(join(tmpdir(),'easel-policy-'));t.after(()=>rm(root,{recursive:true,force:true}));const input=new EventEmitter();Object.assign(input,{isTTY:true,setRawMode(value){this.isRaw=value},resume(){},pause(){}});let text='';const output={isTTY:true,write(value){text+=value}};const session={read:()=>({user:{sub:'owner-a'}})};return{root,input,output,session,text:()=>text};} async function waitInput(input){for(let i=0;i<100&&!input.listenerCount('data');i++)await new Promise(r=>setTimeout(r,1));assert(input.listenerCount('data'));} -test('first use shows required disclosure; quitting stores no acceptance',async t=>{const f=await fixture(t);const waiting=requireSharing(f);await waitInput(f.input);assert.match(f.text(),/requires sharing/);assert.match(f.text(),/retained\s+indefinitely\s+until\s+you\s+delete\s+them/);f.input.emit('data',Buffer.from('q'));assert.equal(await waiting,null);assert.equal(await readAcknowledgment(f.root,'owner-a'),null);}); +test('first use shows required disclosure; quitting stores no acceptance',async t=>{const f=await fixture(t);const waiting=requireSharing(f);await waitInput(f.input);assert.match(f.text(),/agree that aesel shares/);assert.match(f.text(),/keeps\s+them\s+until\s+you\s+delete\s+them/);f.input.emit('data',Buffer.from('q'));assert.equal(await waiting,null);assert.equal(await readAcknowledgment(f.root,'owner-a'),null);}); test('only explicit agreement records account-bound version; restart skips and another account does not inherit it',async t=>{const f=await fixture(t);const waiting=requireSharing(f);await waitInput(f.input);f.input.emit('data',Buffer.from('\r'));assert.equal(await readAcknowledgment(f.root,'owner-a'),null);f.input.emit('data',Buffer.from('a'));const receipt=await waiting;assert.equal(receipt.version,DISCLOSURE_VERSION);assert.equal(receipt.owner,'owner-a');assert.deepEqual(await requireSharing(f),receipt);assert.equal(await readAcknowledgment(f.root,'owner-b'),null);}); test('noninteractive runs cannot silently accept disclosure',async t=>{const f=await fixture(t);f.input.isTTY=false;await assert.rejects(requireSharing(f),/interactively/);}); test('an old retention acknowledgment requires agreement to the new disclosure',async t=>{ const f=await fixture(t),owner='owner-a'; const file=join(f.root,createHash('sha256').update(owner).digest('hex')+'.json'); - await writeFile(file,JSON.stringify({owner,version:3,acceptedAt:new Date().toISOString()})); + await writeFile(file,JSON.stringify({owner,version:DISCLOSURE_VERSION-1,acceptedAt:new Date().toISOString()})); assert.equal(await readAcknowledgment(f.root,owner),null); const waiting=requireSharing(f);await waitInput(f.input); - assert.match(f.text(),/retained\s+indefinitely/); + assert.match(f.text(),/keeps\s+them\s+until\s+you\s+delete\s+them/); f.input.emit('data',Buffer.from('a')); - assert.equal((await waiting).version,4); + assert.equal((await waiting).version,DISCLOSURE_VERSION); }); // ── the screen itself ─────────────────────────────────────────────────── @@ -27,30 +27,32 @@ test("the disclosure stands in the middle of the window under the donkey", () => const drawn = rows.filter((row) => row.trim()); assert.ok(rows.findIndex((row) => row.trim()) > 4, "blank rows above the block put it in the middle, not the corner"); assert.ok(drawn.some((row) => /\( [o-] [o-] \)/.test(row)), "the donkey is on the screen, eyes open or mid-blink"); - const paragraph = rows.find((row) => row.includes("aesel requires sharing")); + const paragraph = rows.find((row) => row.includes("By continuing you agree")); const indent = paragraph.length - paragraph.trimStart().length; assert.ok(indent >= (120 - DISCLOSURE_COLUMNS) / 2 - 1, "the block is centred horizontally"); assert.ok(Math.max(...drawn.map((row) => row.trimEnd().length - indent)) <= DISCLOSURE_COLUMNS, "no line runs past the paragraph's measure"); - const donkey = rows.find((row) => row.includes("/\\ /\\")); - assert.ok(donkey.length - donkey.trimStart().length > indent, "the donkey stands over the middle of the words, not their left edge"); + const donkeyRows = rows.filter((row) => /^\s+[/(\\ ]/.test(row) && !row.includes("agree") && !row.includes("[A]")).slice(0, 8); + const lefts = donkeyRows.map((row) => row.length - row.trimStart().length); + assert.ok(Math.min(...lefts) > indent, "the donkey stands over the middle of the words, not their left edge"); + assert.ok(Math.min(...lefts) === lefts[0] - 3 || lefts.every((l) => l >= lefts[0] - 3), "he moves as one picture"); const words = drawn.map((row) => row.trim()).join(" "); for (const word of TRANSCRIPT_DISCLOSURE.split(/\s+/).slice(0, 12)) assert.ok(words.includes(word), word); - assert.ok(drawn.at(-1).includes("[A] Agree and continue (sign in)"), "signed out asks to sign in"); + assert.equal(drawn.at(-1).trim(), "[A] Agree [Q] Quit", "the whole choice: agree, or quit"); }); test("colour inks the star on its own, and a narrow window still fits", () => { const painted = sharingScreen({ columns: 100, rows: 30, useColor: true, signedIn: true }); assert.match(painted, /\x1b\[[0-9;]*m\*/, "the star on the canvas is inked on its own"); - assert.ok(!plainRows(painted).at(-2).includes("(sign in)"), "signed in does not ask to sign in"); assert.ok(plainRows(sharingScreen({ columns: 44, rows: 12, useColor: false })).every((row) => row.length <= 44), "nothing wraps at 44 columns"); }); -test("an 80 by 24 terminal shows the whole disclosure and the keys, with the small donkey", () => { +test("an 80 by 24 terminal shows the whole donkey, the sentence and the keys; a short one keeps the words", () => { const screen = sharingScreen({ columns: 80, rows: 24, useColor: false }); const rows = plainRows(screen); assert.ok(rows.length <= 25, `fits in 24 rows, got ${rows.length}`); - assert.ok(!rows.some((row) => row.includes("( o o )") || row.includes("( - - )")), "no room for the full donkey"); - assert.ok(rows.some((row) => /\/\/o>\s+Aesel data sharing/.test(row)), "his one-row self stands by the title"); + assert.ok(rows.some((row) => /\( [o-] [o-] \)/.test(row)), "one sentence leaves room for all of him"); + const short = plainRows(sharingScreen({ columns: 80, rows: 12, useColor: false })); + assert.ok(short.length <= 13 && short.some((row) => /\/\/o>\s+aesel/.test(row)), "his one-row self stands in when the window is short"); assert.ok(rows.some((row) => row.includes("[Q] Quit")), "the keys are on screen"); assert.ok(rows.every((row) => row.length <= 80), "nothing wraps"); }); -- 2.51.2