this repo has no description

fix: track noteHash separately to detect changes beyond 10k chars

space.remanso.note supports up to 30k chars while site.standard.document
only captures the first 10k chars of stripped text. A single contentHash
was insufficient to detect note-only changes in the 10k-30k range,
especially after running sync.

- Add computeNoteHash() hashing raw markdown content up to 30k chars
plus note-specific frontmatter fields (theme, fontSize, fontFamily,
discoverable)
- Store noteHash independently in state, written only after a successful
note create/update — so silent failures are retried on the next run
- Change detection now checks contentHash (document) and noteHash (note)
independently; only the changed record is updated
- Fix sync's matchesPDS to also compare the first 5k chars of note body
content against PDS, preventing incorrect "in sync" verdicts for notes
with content only in the 10k-30k range

+88 -24
+13
packages/cli/src/lib/markdown.ts
··· 393 393 } 394 394 return stripMarkdownForText(post.content); 395 395 } 396 + 397 + const NOTE_CONTENT_MAX = 30000; 398 + 399 + export async function computeNoteHash(post: BlogPost): Promise<string> { 400 + const key = [ 401 + post.content.trim().slice(0, NOTE_CONTENT_MAX), 402 + post.frontmatter.theme ?? "", 403 + String(post.frontmatter.fontSize ?? ""), 404 + post.frontmatter.fontFamily ?? "", 405 + String(post.frontmatter.discoverable ?? true), 406 + ].join("\0"); 407 + return getContentHash(key); 408 + }
+1
packages/cli/src/lib/types.ts
··· 124 124 125 125 export interface PostState { 126 126 contentHash: string; 127 + noteHash?: string; // hash of note-effective fields (content[0:30000], theme, etc.) 127 128 atUri?: string; 128 129 lastPublished?: string; 129 130 slug?: string; // The generated slug for this post (used by inject command)
+52 -19
packages/remanso-cli/src/commands/publish.ts
··· 23 23 getContentHash, 24 24 updateFrontmatterWithAtUri, 25 25 slugifyTitle, 26 + computeNoteHash, 26 27 } from "../../../cli/src/lib/markdown"; 27 28 import type { 28 29 BlogPost, ··· 207 208 post: BlogPost; 208 209 action: "create" | "update"; 209 210 reason: "content changed" | "forced" | "new post" | "missing state"; 211 + updateDocument: boolean; 212 + updateNote: boolean; 210 213 }> = []; 211 214 const draftPosts: BlogPost[] = []; 212 215 ··· 216 219 continue; 217 220 } 218 221 219 - const contentHash = await getContentHash(post.rawContent); 220 222 const relativeFilePath = path.relative(configDir, post.filePath); 221 223 const postState = state.posts[relativeFilePath]; 222 224 ··· 225 227 post, 226 228 action: post.frontmatter.atUri ? "update" : "create", 227 229 reason: "forced", 230 + updateDocument: true, 231 + updateNote: true, 228 232 }); 229 233 } else if (!postState) { 230 234 postsToPublish.push({ 231 235 post, 232 236 action: post.frontmatter.atUri ? "update" : "create", 233 237 reason: post.frontmatter.atUri ? "missing state" : "new post", 234 - }); 235 - } else if (postState.contentHash !== contentHash) { 236 - postsToPublish.push({ 237 - post, 238 - action: post.frontmatter.atUri ? "update" : "create", 239 - reason: "content changed", 238 + updateDocument: true, 239 + updateNote: true, 240 240 }); 241 + } else { 242 + const contentHash = await getContentHash(post.rawContent); 243 + const noteHash = await computeNoteHash(post); 244 + 245 + const documentChanged = postState.contentHash !== contentHash; 246 + // Treat absence of noteHash (legacy state) as changed so we populate it 247 + const noteChanged = postState.noteHash 248 + ? postState.noteHash !== noteHash 249 + : true; 250 + 251 + if (documentChanged || noteChanged) { 252 + postsToPublish.push({ 253 + post, 254 + action: post.frontmatter.atUri ? "update" : "create", 255 + reason: "content changed", 256 + updateDocument: documentChanged, 257 + updateNote: noteChanged, 258 + }); 259 + } 241 260 } 242 261 } 243 262 ··· 363 382 post: BlogPost; 364 383 action: "create" | "update"; 365 384 atUri: string; 385 + updateNote: boolean; 366 386 }> = []; 367 387 368 - for (const { post, action } of postsToPublish) { 388 + for (const { post, action, updateDocument: shouldUpdateDoc, updateNote: shouldUpdateNote } of postsToPublish) { 369 389 const trimmedContent = post.content.trim(); 370 390 const titleMatch = trimmedContent.match(/^# (.+)$/m); 371 391 const title = titleMatch ? titleMatch[1] : post.frontmatter.title; ··· 378 398 } 379 399 380 400 try { 381 - // Handle cover image upload 401 + // Handle cover image upload (needed for both document and note) 382 402 let coverImage: BlobObject | undefined; 383 403 if (post.frontmatter.ogImage) { 384 404 const imagePath = await resolveImagePath( ··· 424 444 publishedCount++; 425 445 } else { 426 446 atUri = post.frontmatter.atUri!; 427 - await updateDocument( 428 - agent, 429 - post, 430 - atUri, 431 - publisherConfig as Parameters<typeof updateDocument>[3], 432 - coverImage, 433 - ); 447 + if (shouldUpdateDoc) { 448 + await updateDocument( 449 + agent, 450 + post, 451 + atUri, 452 + publisherConfig as Parameters<typeof updateDocument>[3], 453 + coverImage, 454 + ); 455 + } 434 456 s.stop(`Updated: ${atUri}`); 435 457 436 458 contentForHash = post.rawContent; 437 459 updatedCount++; 438 460 } 439 461 440 - // Update state 462 + // Update state — contentHash updated here (Pass 1 success) 463 + // noteHash is updated separately in Pass 2 after note write succeeds 441 464 const contentHash = await getContentHash(contentForHash); 465 + const existingState = state.posts[relativeFilePath]; 442 466 state.posts[relativeFilePath] = { 443 467 contentHash, 468 + // Preserve existing noteHash so Pass 2 can update it separately 469 + noteHash: existingState?.noteHash, 444 470 atUri, 445 471 lastPublished: new Date().toISOString(), 446 472 slug: post.slug, 447 473 }; 448 474 449 - noteQueue.push({ post, action, atUri }); 475 + noteQueue.push({ post, action, atUri, updateNote: shouldUpdateNote }); 450 476 } catch (error) { 451 477 const errorMessage = 452 478 error instanceof Error ? error.message : String(error); ··· 457 483 } 458 484 459 485 // Pass 2: Create/update Remanso notes 460 - for (const { post, action, atUri } of noteQueue) { 486 + for (const { post, action, atUri, updateNote: shouldUpdateNote } of noteQueue) { 487 + if (!shouldUpdateNote && action !== "create") continue; 488 + const relativeFilePath = path.relative(configDir, post.filePath); 461 489 try { 462 490 if (action === "create") { 463 491 await createNote(agent, post, atUri, context); 464 492 } else { 465 493 await updateNote(agent, post, atUri, context); 494 + } 495 + // Store noteHash only after the note record is successfully written 496 + const noteHash = await computeNoteHash(post); 497 + if (state.posts[relativeFilePath]) { 498 + state.posts[relativeFilePath]!.noteHash = noteHash; 466 499 } 467 500 } catch (error) { 468 501 log.warn(
+22 -5
packages/remanso-cli/src/commands/sync.ts
··· 17 17 getContentHash, 18 18 getTextContent, 19 19 updateFrontmatterWithAtUri, 20 + computeNoteHash, 20 21 } from "../../../cli/src/lib/markdown"; 21 22 import { exitOnCancel } from "../../../cli/src/lib/prompts"; 22 23 ··· 48 49 return false; 49 50 } 50 51 51 - // Compare note-specific fields: theme, fontSize, fontFamily 52 + // Compare note-specific fields: theme, fontSize, fontFamily, and body content 52 53 const noteUriMatch = doc.uri.match(/^at:\/\/([^/]+)\/[^/]+\/(.+)$/); 53 54 if (noteUriMatch) { 54 55 const repo = noteUriMatch[1]!; ··· 70 71 (localPost.frontmatter.fontFamily || undefined) !== 71 72 (noteValue.fontFamily as string | undefined) || 72 73 localDiscoverable !== noteDiscoverable 74 + ) { 75 + return false; 76 + } 77 + 78 + // Compare note body content up to 5000 chars. 79 + // Beyond that, image paths are transformed to blob links in PDS, 80 + // making direct comparison unreliable without re-uploading images. 81 + const pdsNoteContent = (noteValue.content as string | undefined) ?? ""; 82 + const localNoteContent = localPost.content.trim(); 83 + const compareLength = Math.min(localNoteContent.length, 5000); 84 + if ( 85 + compareLength > 0 && 86 + pdsNoteContent.slice(0, compareLength) !== 87 + localNoteContent.slice(0, compareLength) 73 88 ) { 74 89 return false; 75 90 } ··· 235 250 log.message(` File: ${path.basename(localPost.filePath)}`); 236 251 237 252 const contentMatchesPDS = await matchesPDS(localPost, doc, agent); 238 - const contentHash = contentMatchesPDS 239 - ? await getContentHash(localPost.rawContent) 240 - : ""; 241 253 const relativeFilePath = path.relative(configDir, localPost.filePath); 242 254 state.posts[relativeFilePath] = { 243 - contentHash, 255 + contentHash: contentMatchesPDS 256 + ? await getContentHash(localPost.rawContent) 257 + : "", 258 + noteHash: contentMatchesPDS 259 + ? await computeNoteHash(localPost) 260 + : "", 244 261 atUri: doc.uri, 245 262 lastPublished: doc.value.publishedAt, 246 263 };