nebulosity 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +11 -11
  3. package/.next/diagnostics/route-bundle-stats.json +10 -10
  4. package/.next/fallback-build-manifest.json +3 -3
  5. package/.next/prerender-manifest.json +5 -5
  6. package/.next/server/app/_global-error/page/build-manifest.json +3 -3
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page/build-manifest.json +3 -3
  14. package/.next/server/app/_not-found/page/next-font-manifest.json +2 -3
  15. package/.next/server/app/_not-found/page.js +3 -2
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/.next/server/app/_not-found.html +1 -1
  18. package/.next/server/app/_not-found.rsc +27 -20
  19. package/.next/server/app/_not-found.segments/_full.segment.rsc +27 -20
  20. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +34 -26
  21. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  22. package/.next/server/app/index.html +1 -1
  23. package/.next/server/app/index.rsc +29 -23
  24. package/.next/server/app/index.segments/__PAGE__.segment.rsc +33 -24
  25. package/.next/server/app/index.segments/_full.segment.rsc +29 -23
  26. package/.next/server/app/index.segments/_tree.segment.rsc +4 -5
  27. package/.next/server/app/page/build-manifest.json +3 -3
  28. package/.next/server/app/page/next-font-manifest.json +2 -3
  29. package/.next/server/app/page.js +4 -4
  30. package/.next/server/app/page_client-reference-manifest.js +1 -1
  31. package/.next/server/chunks/[root-of-the-server]__06blyow8irfw3._.js +3 -3
  32. package/.next/server/chunks/[root-of-the-server]__0bmqko-d8fd5k._.js +3 -3
  33. package/.next/server/chunks/[root-of-the-server]__0cmmlmvta4i0g._.js +3 -3
  34. package/.next/server/chunks/[root-of-the-server]__0jgr1dt1j95ku._.js +3 -3
  35. package/.next/server/chunks/[root-of-the-server]__0m0n163465-zb._.js +3 -3
  36. package/.next/server/chunks/[root-of-the-server]__0p7epsgp2b5m3._.js +3 -3
  37. package/.next/server/chunks/[root-of-the-server]__1i5e5sqg45ens._.js +3 -3
  38. package/.next/server/chunks/[root-of-the-server]__1vjt0neva-819._.js +3 -3
  39. package/.next/server/chunks/_0uqus0ehnijt-._.js +1 -1
  40. package/.next/server/chunks/lib_background_start_ts_11u_1uu8b9yuy._.js +1 -1
  41. package/.next/server/chunks/ssr/[root-of-the-server]__0_3l5tdzqy-td._.js +3 -0
  42. package/.next/server/chunks/ssr/[root-of-the-server]__10ockmlli-lun._.js +3 -0
  43. package/.next/server/chunks/ssr/{[root-of-the-server]__0dyligeswbb47._.js → [root-of-the-server]__16-gclvix9mjd._.js} +1 -1
  44. package/.next/server/chunks/ssr/[root-of-the-server]__1auodc3c0rppy._.js +3 -0
  45. package/.next/server/chunks/ssr/[root-of-the-server]__1cf6qt9agg_9-._.js +3 -3
  46. package/.next/server/chunks/ssr/[root-of-the-server]__1i9r59yk4i69p._.js +3 -0
  47. package/.next/server/chunks/ssr/[root-of-the-server]__1l3qp8u9yc_ig._.js +3 -0
  48. package/.next/server/chunks/ssr/_1p-0wsx9qfm68._.js +13 -0
  49. package/.next/server/chunks/ssr/app_map_18iu6j19jsqmu._.js +3 -0
  50. package/.next/server/chunks/ssr/node_modules_next_dist_1r3iqtbm45lqp._.js +3 -0
  51. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0q-w892nagajq.js +3 -0
  52. package/.next/server/middleware-build-manifest.js +11 -11
  53. package/.next/server/next-font-manifest.js +1 -1
  54. package/.next/server/next-font-manifest.json +4 -6
  55. package/.next/server/pages/404.html +1 -1
  56. package/.next/server/pages/500.html +1 -1
  57. package/.next/server/preview-props.json +3 -3
  58. package/.next/server/server-reference-manifest.js +1 -1
  59. package/.next/server/server-reference-manifest.json +1 -1
  60. package/.next/static/chunks/{1rljem7o1jtdo.js → 18kfexnrqek07.js} +1 -1
  61. package/.next/static/chunks/1hwbgd4nvvhg8.js +1 -0
  62. package/.next/static/chunks/25r4lkkf1shfo.css +3 -0
  63. package/.next/static/chunks/35ubvk2ivdjgs.js +1 -0
  64. package/.next/static/chunks/3ieqvmcg3hofj.js +31 -0
  65. package/.next/static/chunks/3w00e1w2125pp.js +11 -0
  66. package/.next/static/media/733da6bd8dac84c8-s.3hlisc-pwq13z.woff2 +0 -0
  67. package/.next/static/media/784318cea1dfcfa4-s.0-oo9_1x_xmvg.woff2 +0 -0
  68. package/.next/static/media/7e114457660b62ed-s.36fqqi66-3tjb.woff2 +0 -0
  69. package/.next/static/media/85e86a88f68a624a-s.357dd_6abeqge.woff2 +0 -0
  70. package/.next/static/media/9a321a2c78eff0b5-s.p.1bkzbscqrt8rl.woff2 +0 -0
  71. package/.next/static/media/9fecbd3e5ea9bccb-s.3_z45zcoc-xoz.woff2 +0 -0
  72. package/.next/static/media/c64053b510252f55-s.1kd-r_s9smv5m.woff2 +0 -0
  73. package/.next/static/media/c6a5c2fdc12da45c-s.p.17-aodiw50953.woff2 +0 -0
  74. package/.next/static/media/f263782c6a4bddee-s.1gyfqm5yhhzs7.woff2 +0 -0
  75. package/.next/trace +1 -1
  76. package/.next/trace-build +1 -1
  77. package/README.md +13 -4
  78. package/app/globals.css +91 -54
  79. package/app/layout.tsx +20 -11
  80. package/app/map/BackButton.tsx +5 -5
  81. package/app/map/BoxActions.tsx +20 -12
  82. package/app/map/Breadcrumb.tsx +132 -46
  83. package/app/map/DetailPanel.tsx +408 -297
  84. package/app/map/Explorer.tsx +268 -0
  85. package/app/map/Header.tsx +105 -0
  86. package/app/map/HelpDialog.tsx +37 -34
  87. package/app/map/Legend.tsx +19 -75
  88. package/app/map/LineCard.tsx +187 -0
  89. package/app/map/MapBox.tsx +73 -38
  90. package/app/map/MapCanvas.tsx +344 -115
  91. package/app/map/MapToolbar.tsx +377 -0
  92. package/app/map/MapView.tsx +217 -213
  93. package/app/map/RepoMenu.tsx +13 -12
  94. package/app/map/RoutedEdge.tsx +48 -16
  95. package/app/map/SearchPalette.tsx +6 -6
  96. package/app/map/SourceViewer.tsx +19 -17
  97. package/app/map/StatusBar.tsx +166 -0
  98. package/app/map/embed.ts +22 -2
  99. package/app/map/emphasis.ts +94 -8
  100. package/app/map/explorerRows.ts +85 -0
  101. package/app/map/flowNodes.ts +12 -7
  102. package/app/map/format.ts +2 -2
  103. package/app/map/history.ts +4 -3
  104. package/app/map/inspector.ts +113 -0
  105. package/app/map/labels.ts +66 -7
  106. package/app/map/narrow.ts +165 -85
  107. package/app/map/opening.ts +16 -0
  108. package/app/map/portals.ts +2 -2
  109. package/app/map/routes.ts +1060 -55
  110. package/app/map/runs.ts +108 -0
  111. package/app/map/shell.ts +91 -0
  112. package/app/map/shortcuts.ts +2 -1
  113. package/app/map/summaries.ts +41 -5
  114. package/app/map/text.ts +9 -1
  115. package/app/map/theme.ts +197 -0
  116. package/app/map/useDismiss.ts +20 -6
  117. package/app/map/useEditorBridge.ts +12 -1
  118. package/app/map/useThemeKind.ts +28 -0
  119. package/app/map/useWindowSize.ts +37 -0
  120. package/app/map/viewport.ts +134 -16
  121. package/app/map/weights.ts +80 -0
  122. package/app/page.tsx +3 -5
  123. package/lib/background/child.ts +2 -0
  124. package/lib/background/service.ts +26 -10
  125. package/lib/contracts.ts +25 -2
  126. package/lib/db/migrations/0011_plain_summaries_prompt_version.sql +1 -0
  127. package/lib/db/migrations/meta/0011_snapshot.json +1498 -0
  128. package/lib/db/migrations/meta/_journal.json +7 -0
  129. package/lib/db/schema.ts +2 -1
  130. package/lib/db/store/map.ts +6 -0
  131. package/lib/db/store/summaries.ts +14 -8
  132. package/lib/engine/describe.ts +2 -0
  133. package/lib/index/summarize/budget.ts +5 -2
  134. package/lib/index/summarize/claude.ts +19 -4
  135. package/lib/index/summarize/compare.ts +2 -2
  136. package/lib/index/summarize/plan.ts +73 -13
  137. package/lib/index/summarize/preview.ts +12 -9
  138. package/lib/index/summarize/prompts.ts +122 -25
  139. package/lib/index/summarize/run.ts +48 -18
  140. package/lib/index/summarize/snapshot.ts +7 -2
  141. package/lib/index/summarize/status.ts +3 -2
  142. package/lib/tools/text.ts +3 -2
  143. package/mcp/server.ts +1 -1
  144. package/package.json +4 -2
  145. package/scripts/commands/summarize-command.ts +27 -9
  146. package/.next/server/chunks/ssr/[root-of-the-server]__0ekw8p2ramybv._.js +0 -3
  147. package/.next/server/chunks/ssr/[root-of-the-server]__0fwbsqt9e2tr-._.js +0 -3
  148. package/.next/server/chunks/ssr/[root-of-the-server]__17_rqmx62cz59._.js +0 -3
  149. package/.next/server/chunks/ssr/[root-of-the-server]__1zfp6sltse4hf._.js +0 -3
  150. package/.next/server/chunks/ssr/_1d2woi6toj_0_._.js +0 -3
  151. package/.next/server/chunks/ssr/_1wfc1zz26g5ji._.js +0 -9
  152. package/.next/server/chunks/ssr/app_0gw6epmxyea3q._.js +0 -3
  153. package/.next/server/chunks/ssr/node_modules_@swc_helpers_cjs__interop_require_default_cjs_1ztp13a5szri_._.js +0 -3
  154. package/.next/server/chunks/ssr/node_modules_next_dist_1c5t_vsn6u8fr._.js +0 -3
  155. package/.next/static/chunks/02x9pxyl2d8om.css +0 -3
  156. package/.next/static/chunks/0p-0bv2dzer18.js +0 -7
  157. package/.next/static/chunks/10u7p2gj02qay.js +0 -1
  158. package/.next/static/chunks/31qp8jfkw0x2h.js +0 -31
  159. package/.next/static/media/123d516c9baeb953-s.1qc6dx89jd2qv.woff2 +0 -0
  160. package/.next/static/media/41ba59f553543ecf-s.3cb5lpr2l6xud.woff2 +0 -0
  161. package/.next/static/media/44ed10da57ebfb0e-s.2y65deuc30pmy.woff2 +0 -0
  162. package/.next/static/media/48eea41d8317494f-s.p.0wiir8udbzjvx.woff2 +0 -0
  163. package/.next/static/media/49b1d53681a91a99-s.1yd4_i3e35931.woff2 +0 -0
  164. package/.next/static/media/67bba87670b402d1-s.1u30mmyq6lnsd.woff2 +0 -0
  165. package/.next/static/media/6fca7e74603dd412-s.02z5rrbg5nt92.woff2 +0 -0
  166. package/.next/static/media/73263b2957b73100-s.2qt3tdwt-y9x_.woff2 +0 -0
  167. package/.next/static/media/7ac0b1cd3e695dd0-s.1i-sca43-qwng.woff2 +0 -0
  168. package/.next/static/media/916cd8a3bde3edf8-s.p.0oace-s_gkfks.woff2 +0 -0
  169. package/.next/static/media/a45aa1001bf9176f-s.2vemzm0itqzfy.woff2 +0 -0
  170. package/.next/static/media/a74d2542cc4c775f-s.29yke99gs9qpn.woff2 +0 -0
  171. package/.next/static/media/ab664c0e419cc841-s.3cvcemwuzoywn.woff2 +0 -0
  172. package/.next/static/media/c1b11b726b36a5e5-s.2rixgazldzomk.woff2 +0 -0
  173. package/.next/static/media/d6c1ff7e86974212-s.p.40sczeszzbjw1.woff2 +0 -0
  174. package/.next/static/media/faab88e4be272d29-s.0rgnxg2b64rzs.woff2 +0 -0
  175. package/app/map/ActivityBar.tsx +0 -81
  176. package/app/map/StatusPill.tsx +0 -93
  177. package/app/map/TopBar.tsx +0 -84
  178. /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_buildManifest.js +0 -0
  179. /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_clientMiddlewareManifest.js +0 -0
  180. /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_ssgManifest.js +0 -0
@@ -78,6 +78,13 @@
78
78
  "when": 1792890069157,
79
79
  "tag": "0010_e4_declares",
80
80
  "breakpoints": true
81
+ },
82
+ {
83
+ "idx": 11,
84
+ "version": "6",
85
+ "when": 1793261385735,
86
+ "tag": "0011_plain_summaries_prompt_version",
87
+ "breakpoints": true
81
88
  }
82
89
  ]
83
90
  }
package/lib/db/schema.ts CHANGED
@@ -156,9 +156,10 @@ export const summaries = sqliteTable("summaries", {
156
156
  targetKind: text("target_kind", { enum: SUMMARY_TARGET_KINDS }).notNull(),
157
157
  targetPath: text("target_path").notNull(), // "" for the repo itself
158
158
  tagline: text("tagline"), // 3–6 words asked for, up to 8 kept whole; null until a summary exists
159
- summary: text("summary"), // 2–4 sentences
159
+ summary: text("summary"), // short paragraphs, separated by blank lines
160
160
  model: text("model"), // the model that wrote it, or "template"
161
161
  inputHash: text("input_hash"), // hash(model, prompt version, input) of the text above
162
+ promptVersion: integer("prompt_version"), // the prompt version that wrote the text above, null for a template or for text from before v3 (ADR-034)
162
163
  status: text("status", { enum: SUMMARY_STATUSES }).notNull(),
163
164
  error: text("error"), // why the last attempt failed
164
165
  // A Message Batch can take hours. These two say which batch carries this row's request and
@@ -16,6 +16,10 @@ export type SummaryState = {
16
16
  path: string;
17
17
  tagline: string | null;
18
18
  summary: string | null;
19
+ /** The model that wrote the text, or "template". */
20
+ model: string | null;
21
+ /** The prompt version that wrote the text, null for a template or for text from before v3 (ADR-034). */
22
+ promptVersion: number | null;
19
23
  status: StoredSummaryStatus;
20
24
  error: string | null;
21
25
  };
@@ -40,6 +44,8 @@ const summaryColumns = {
40
44
  path: summaries.targetPath,
41
45
  tagline: summaries.tagline,
42
46
  summary: summaries.summary,
47
+ model: summaries.model,
48
+ promptVersion: summaries.promptVersion,
43
49
  status: summaries.status,
44
50
  error: summaries.error,
45
51
  };
@@ -20,6 +20,8 @@ export type StoredSummary = {
20
20
  summary: string | null;
21
21
  model: string | null;
22
22
  inputHash: string | null;
23
+ /** The prompt version that wrote the text, null for a template or for text from before v3 (ADR-034). */
24
+ promptVersion: number | null;
23
25
  status: StoredSummaryStatus;
24
26
  batchId: string | null;
25
27
  error: string | null;
@@ -53,7 +55,7 @@ export type SummaryResult = {
53
55
  batchId: string | null;
54
56
  /** The input the answer was written for. */
55
57
  inputHash: string;
56
- outcome: { ok: true; text: SummaryText; model: string } | { ok: false; error: string };
58
+ outcome: { ok: true; text: SummaryText; model: string; promptVersion: number } | { ok: false; error: string };
57
59
  };
58
60
 
59
61
  export type UsageRow = { model: string; transport: "direct" | "batch"; requests: number; inputTokens: number; outputTokens: number };
@@ -117,6 +119,7 @@ export function loadSummaries(db: Db, repoId: number): StoredSummary[] {
117
119
  summary: summaries.summary,
118
120
  model: summaries.model,
119
121
  inputHash: summaries.inputHash,
122
+ promptVersion: summaries.promptVersion,
120
123
  status: summaries.status,
121
124
  batchId: summaries.batchId,
122
125
  error: summaries.error,
@@ -165,7 +168,7 @@ export function applyTargetUpdates(db: Db, repoId: number, updates: readonly Tar
165
168
 
166
169
  if (u.templateText){
167
170
  const result = tx.update(summaries).set({
168
- tagline: u.templateText.tagline, summary: u.templateText.summary, model: "template",
171
+ tagline: u.templateText.tagline, summary: u.templateText.summary, model: "template", promptVersion: null,
169
172
  inputHash: u.inputHash, status: "skipped", error: null, updatedAt: now,
170
173
  }).where(row).run();
171
174
  if (result.changes > 0) changed = true;
@@ -217,11 +220,11 @@ export function loadBatchRows(db: Db, batchId: string): { id: number; kind: Summ
217
220
 
218
221
  /**
219
222
  * Writes answers back and returns how many rows changed.
220
- * - A summary lands as "fresh" with its text and inputHash. Its old vector is deleted, so
221
- * the embeddings stage redoes it.
222
- * - A failure becomes "failed" and keeps any older text on show. A failure from the regular API
223
- * leaves a batch's claim on the row in place: that request is already paid for, and its
224
- * answer can still land.
223
+ * - A summary lands as "fresh" with its text, inputHash and prompt version. Its old vector is
224
+ * deleted, so the embeddings stage redoes it.
225
+ * - A failure becomes "failed" and keeps any older text on show, with the prompt version that
226
+ * wrote it. A failure from the regular API leaves a batch's claim on the row in place: that
227
+ * request is already paid for, and its answer can still land.
225
228
  * - An answer from a batch that no longer owns the row (the row was redone some other way
226
229
  * meanwhile) is dropped.
227
230
  */
@@ -234,7 +237,10 @@ export function writeResults(db: Db, results: readonly SummaryResult[]): number
234
237
  for (const r of results){
235
238
  const owner = r.batchId === null ? eq(summaries.id, r.id) : and(eq(summaries.id, r.id), eq(summaries.batchId, r.batchId));
236
239
  const answer = r.outcome.ok
237
- ? { tagline: r.outcome.text.tagline, summary: r.outcome.text.summary, model: r.outcome.model, inputHash: r.inputHash, status: "fresh" as const, error: null }
240
+ ? {
241
+ tagline: r.outcome.text.tagline, summary: r.outcome.text.summary, model: r.outcome.model, promptVersion: r.outcome.promptVersion,
242
+ inputHash: r.inputHash, status: "fresh" as const, error: null,
243
+ }
238
244
  : { status: "failed" as const, error: r.outcome.error };
239
245
 
240
246
  // Only a regular-API failure keeps the row's claim: a regular-API success supersedes the batch,
@@ -6,6 +6,7 @@ import { requireRepository } from "../db/store/structure";
6
6
  import { readIndexVersion } from "../db/store/version";
7
7
  import { DEFAULT_MAX_FILE_BYTES } from "../index/discover";
8
8
  import { repoKey } from "../index/paths";
9
+ import { writtenInOlderStyle } from "../index/summarize/prompts";
9
10
  import { docsAbout, docsToReadFirst } from "./docs";
10
11
  import { stalePaths } from "./freshness";
11
12
  import { cachedAreaCounts, type TargetKind } from "./graph";
@@ -72,6 +73,7 @@ function describeUncached(db: Db, repo: string, repoId: number, version: number,
72
73
  ...(row?.tagline ? { tagline: row.tagline, summary: row.summary ?? "" } : {}),
73
74
  summaryStatus,
74
75
  ...(summaryStatus === "failed" && row?.error ? { summaryError: row.error } : {}),
76
+ ...(row && writtenInOlderStyle(row) ? { summaryOlderStyle: true as const } : {}),
75
77
  ...(docs.length > 0 ? { docs } : {}),
76
78
  stats: folder
77
79
  ? { fileCount: folder.fileCount, lineCount: sumLinesInside(db, repoId, targetPath) }
@@ -16,8 +16,11 @@ export const ASSUMED_TOKENS: Readonly<Record<SummaryTargetKind, TokenUsage>> = {
16
16
  repo: { inputTokens: 1_000, outputTokens: 250 },
17
17
  };
18
18
 
19
- /** One request the projection prices: the kind of target it's for, and the model that answers it. */
20
- export type ProjectedRequest = { kind: SummaryTargetKind; model: string };
19
+ /**
20
+ * One request the projection prices: the kind of target it's for, and the model that answers it. restyle: it's sent
21
+ * only because an older prompt version wrote its summary (PlannedTarget.restyle), so the estimate can say how many are.
22
+ */
23
+ export type ProjectedRequest = { kind: SummaryTargetKind; model: string; restyle?: true };
21
24
 
22
25
  type Billing = "direct" | "batch";
23
26
 
@@ -14,7 +14,7 @@ export const SUMMARY_SCHEMA = {
14
14
  type: "object",
15
15
  properties: {
16
16
  tagline: { type: "string", description: "A noun phrase of 3 to 6 plain words." },
17
- summary: { type: "string", description: "2 to 4 sentences." },
17
+ summary: { type: "string", description: "Short paragraphs separated by a blank line, about the length the request gives." },
18
18
  },
19
19
  required: ["tagline", "summary"],
20
20
  additionalProperties: false,
@@ -32,6 +32,20 @@ const SENTENCE_END = /[.!?]["'`)\]]*$/;
32
32
  /** Two or more periods at the very end ("…in one place.."), which read as one. */
33
33
  const TRAILING_PERIODS = /\.{2,}$/;
34
34
 
35
+ /** A paragraph break: two line breaks with only whitespace between them, CRLF and extra blank lines included. */
36
+ const PARAGRAPH_BREAK = /\n\s*\n/;
37
+
38
+ /**
39
+ * A summary's paragraphs in the one shape every reader splits on (P5): exactly one blank line between paragraphs,
40
+ * each trimmed, and a single line break inside one read as a space.
41
+ */
42
+ function normalizeParagraphs(summary: string): string {
43
+ return summary.split(PARAGRAPH_BREAK)
44
+ .map((paragraph) => paragraph.trim().replace(/\s*\n\s*/g, " "))
45
+ .filter((paragraph) => paragraph !== "")
46
+ .join("\n\n");
47
+ }
48
+
35
49
  /** Regular-API retries of a batch's cut-off answers in flight at once: as many as the runner's regular-API waves send. */
36
50
  const RETRY_CONCURRENCY = 4;
37
51
 
@@ -170,10 +184,11 @@ export function parseSummary(reply: Pick<Reply, "stop_reason" | "content">): Sum
170
184
  const tagline = cleanTagline(parsed.data.tagline);
171
185
  if (!tagline) throw new Error("the reply didn't have a tagline and a summary");
172
186
 
173
- // ".." is a slip, not an ellipsis, and so is "...": the run becomes one "." before the check below
174
- const summary = parsed.data.summary.replace(TRAILING_PERIODS, ".");
187
+ // ".." is a slip, not an ellipsis, and so is "...": the run becomes one "." before the check below.
188
+ // Then the paragraphs take their one shape, whatever line breaks the model wrote between them.
189
+ const summary = normalizeParagraphs(parsed.data.summary.replace(TRAILING_PERIODS, "."));
175
190
  // A normal stop doesn't prove the answer is whole: Summary Review #3 found well-formed JSON, stopped at end_turn,
176
- // whose summary ended "…applyEdits. The". A summary is 2 to 4 sentences, so a whole one ends a sentence.
191
+ // whose summary ended "…applyEdits. The". A summary's paragraphs are whole sentences, so a whole one ends a sentence.
177
192
  if (!SENTENCE_END.test(summary)) throw new Error(`${CUT_OFF} (stop reason: end_turn): ${summary === "" ? "it's empty" : "it ends mid-sentence"}`);
178
193
  return { tagline, summary };
179
194
  }
@@ -2,7 +2,7 @@ import type { FileFacts } from "../../db/store/summaries";
2
2
  import { ASSUMED_TOKENS } from "./budget";
3
3
  import { writtenByModel } from "./plan";
4
4
  import { costUsd, formatUsd, pricePerMTok, type PriceTable } from "./pricing";
5
- import { plural, PROMPT_VERSION } from "./prompts";
5
+ import { joinParagraphs, plural, PROMPT_VERSION } from "./prompts";
6
6
  import type { SummaryOutcome } from "./types";
7
7
 
8
8
  /** Folder inputs, assumed for the estimate as the spend cap assumes them (budget.ts): a folder lists its children's taglines and summaries. */
@@ -108,7 +108,7 @@ export function renderComparison(report: ComparisonReport): string {
108
108
  for (const model of report.models){
109
109
  const outcome = row.outcomes[model];
110
110
  lines.push(outcome?.ok
111
- ? `- ${answerLabel(model, outcome.model)} *${outcome.text.tagline}* — ${outcome.text.summary}`
111
+ ? `- ${answerLabel(model, outcome.model)} *${outcome.text.tagline}* — ${joinParagraphs(outcome.text.summary)}`
112
112
  : `- **${model}:** failed: ${outcome ? outcome.error : "no answer"}`);
113
113
  }
114
114
  });
@@ -4,12 +4,14 @@ import { targetKey, type FileFacts, type FolderFacts, type StoredSummary, type S
4
4
  import { DEFAULT_MAX_FILE_BYTES } from "../discover";
5
5
  import { dirOf } from "../paths";
6
6
  import {
7
- importsOnlyFileSummary, PROMPT_VERSION, renderFolderPrompt, renderRepoPrompt, testFileSummary, testFolderSummary, type FolderItem,
7
+ folderTier, importsOnlyFileSummary, MAX_FOLDER_ITEMS, PROMPT_VERSION, renderFolderPrompt, renderRepoPrompt, testFileSummary,
8
+ testFolderSummary, UNRECORDED_PROMPT_VERSION, withLengthLine, writtenInOlderStyle, type FolderItem,
8
9
  } from "./prompts";
9
10
 
10
11
  /**
11
- * current: the text matches the current input · template: a test file or test-only folder whose
12
- * template is missing or out of date, written right away · ready: needs a model and can go now ·
12
+ * current: the text matches the current input, or an older prompt wrote it for this same input (P1) ·
13
+ * template: a test file or test-only folder whose template is missing or out of date, written right away ·
14
+ * ready: needs a model and can go now ·
13
15
  * waiting: needs a model, but a child isn't done yet · lazy: an out-of-date folder left
14
16
  * for later (redone when someone opens it) · in-flight: a batch already carries it ·
15
17
  * failed: failed earlier in this run; the next run retries it
@@ -26,10 +28,15 @@ export type PlannedTarget = {
26
28
  /** The row's status from now on; null leaves it as it is. */
27
29
  status: StoredSummaryStatus | null;
28
30
  templateText?: SummaryText;
29
- /** Folders and the repo. A file's prompt is built from disk when it's sent. */
31
+ /** Folders and the repo, ending with the length line. A file's prompt is built from disk when it's sent. */
30
32
  prompt?: string;
33
+ /** planned to be written only because an older prompt version wrote its text (P1) */
34
+ restyle?: true;
31
35
  };
32
36
 
37
+ /** Which summaries an older prompt wrote a plan restyles (PlanOptions.restyle). */
38
+ export type Restyle = "all" | "none" | { path: string };
39
+
33
40
  export type PlanOptions = {
34
41
  models: { readonly file: string; readonly folder: string; readonly repo: string };
35
42
  /** true redoes out-of-date folders now (a full run); false leaves them lazy until opened (incremental runs). */
@@ -41,6 +48,19 @@ export type PlanOptions = {
41
48
  * cap, DEFAULT_MAX_FILE_BYTES, the product's only one; tests pass another to match an index they built with it.
42
49
  */
43
50
  maxFileBytes?: number;
51
+ /**
52
+ * which summaries an older prompt version wrote, with their input otherwise unchanged, are redone in the current
53
+ * style: every one (a full run and its estimate), the one a panel opened (summarizeNow), or none (the automatic jobs
54
+ * and the stage refresh). Default: "none".
55
+ */
56
+ restyle?: Restyle;
57
+ /**
58
+ * true plans an out-of-date folder or the repo whose text an older prompt version wrote, and which this plan doesn't
59
+ * restyle, as lazy, so it counts as settled and its parent can go over its older text. Under Q2 B only opening it
60
+ * rewrites it, so without this its parent would wait for it for good (the Task 4 review's I1, ruled as option a).
61
+ * summarizeNow and the sweep pass it. A full run and the stage refresh don't. Default: false.
62
+ */
63
+ olderFoldersWaitToBeOpened?: boolean;
44
64
  };
45
65
 
46
66
  /**
@@ -82,6 +102,10 @@ export function planSummaries(
82
102
  return row?.tagline && row.summary !== null ? { tagline: row.tagline, summary: row.summary } : null;
83
103
  };
84
104
 
105
+ // whether this plan restyles the target at `path`: a file and a folder never share a path, and only the repo's is ""
106
+ const restyled = (path: string): boolean =>
107
+ options.restyle === "all" || (typeof options.restyle === "object" && options.restyle.path === path);
108
+
85
109
  // ---- files: independent of each other ---------------------------------------------
86
110
  for (const file of files){
87
111
  const key = targetKey("file", file.path);
@@ -99,9 +123,10 @@ export function planSummaries(
99
123
  }
100
124
 
101
125
  // A file's prompt is built from its bytes, so its content hash stands in for the prompt
102
- // here: same bytes, same prompt. Planning then never has to read source files.
103
- const hash = inputHash(options.models.file, PROMPT_VERSION, `file\n${file.path}\n${file.contentHash}`);
104
- planned.set(key, decide("file", file.path, options.models.file, hash, row, true, options));
126
+ // here: same bytes, same prompt. Planning then never has to read source files. Older
127
+ // prompt versions hashed the same stand-in.
128
+ const input = `file\n${file.path}\n${file.contentHash}`;
129
+ planned.set(key, decide("file", file.path, options.models.file, { input, olderInput: () => input, restyled: restyled(file.path) }, row, true, options));
105
130
  }
106
131
 
107
132
  // ---- folders, deepest first, so every child is planned before its parent ------------
@@ -164,11 +189,21 @@ export function planSummaries(
164
189
  }
165
190
 
166
191
  const model = kind === "repo" ? options.models.repo : options.models.folder;
167
- const prompt = kind === "repo"
168
- ? renderRepoPrompt({ name: folder.name, fileCount: folder.fileCount, folderCount: folders.length }, items)
169
- : renderFolderPrompt(folder, items);
192
+ const repo = { name: folder.name, fileCount: folder.fileCount, folderCount: folders.length };
193
+ // The body is hashed without the length line (P2): the line follows from the file count, which the body
194
+ // holds, and from the prompt version, which the hash holds. The prompt sent ends with it.
195
+ const body = kind === "repo" ? renderRepoPrompt(repo, items) : renderFolderPrompt(folder, items);
196
+ // the body as prompt v2 rendered it, each child's summary verbatim, for the older check (P2): v2 is the only
197
+ // older version that stored summaries
198
+ const olderInput = (): string => (kind === "repo"
199
+ ? renderRepoPrompt(repo, items, MAX_FOLDER_ITEMS, false)
200
+ : renderFolderPrompt(folder, items, MAX_FOLDER_ITEMS, false));
201
+ const prompt = withLengthLine(body, kind, kind === "repo" ? "large" : folderTier(folder.fileCount));
170
202
  const key = targetKey(kind, folder.path);
171
- planned.set(key, { ...decide(kind, folder.path, model, inputHash(model, PROMPT_VERSION, prompt), storedByKey.get(key), settled, options), prompt });
203
+ planned.set(key, {
204
+ ...decide(kind, folder.path, model, { input: body, olderInput, restyled: restyled(folder.path) }, storedByKey.get(key), settled, options),
205
+ prompt,
206
+ });
172
207
  }
173
208
 
174
209
  // ---- queue order --------------------------------------------------------------------
@@ -216,28 +251,53 @@ export function foldersAbove(targets: readonly PlannedTarget[], below: readonly
216
251
  return targets.filter((t) => above.has(targetKey(t.kind, t.path)));
217
252
  }
218
253
 
219
- /** The state of one model-written target, given its stored row and whether its children are settled. */
254
+ /** What decide() hashes for one model-written target, and whether the plan restyles it. */
255
+ type TargetInput = {
256
+ /** The input under the current prompt version: a file's stand-in, or a folder's or the repo's prompt body. */
257
+ input: string;
258
+ /** The input as an older prompt version rendered it (P2), rendered only when a row an older prompt wrote missed input's hash. */
259
+ olderInput: () => string;
260
+ /** Whether PlanOptions.restyle names this target. */
261
+ restyled: boolean;
262
+ };
263
+
264
+ /**
265
+ * The state of one model-written target, given its input, its stored row and whether its children are settled. A row
266
+ * an older prompt wrote for the same input is older style only (P1): current, unless the plan restyles it. One it wrote
267
+ * for another input is out of date, and with olderFoldersWaitToBeOpened a folder or the repo like that is lazy unless
268
+ * the plan restyles it.
269
+ */
220
270
  function decide(
221
271
  kind: SummaryTargetKind,
222
272
  path: string,
223
273
  model: string,
224
- hash: string,
274
+ { input, olderInput, restyled }: TargetInput,
225
275
  row: StoredSummary | undefined,
226
276
  childrenSettled: boolean,
227
277
  options: PlanOptions
228
278
  ): PlannedTarget {
279
+ const hash = inputHash(model, PROMPT_VERSION, input);
229
280
  const base = { kind, path, model, inputHash: hash };
230
281
 
231
282
  if (row?.batchId) return { ...base, state: "in-flight", status: null };
232
283
  if (options.failedThisRun.has(targetKey(kind, path))) return { ...base, state: "failed", status: null };
233
284
  // the text on the row was written for exactly this input (an undone edit lands here too)
234
285
  if (row?.inputHash === hash) return { ...base, state: "current", status: row.status === "fresh" ? null : "fresh" };
286
+ // older style only (P1): a model wrote it with an older prompt, and its input hasn't changed since
287
+ if (row && writtenInOlderStyle(row) && row.inputHash === inputHash(model, row.promptVersion ?? UNRECORDED_PROMPT_VERSION, olderInput())){
288
+ const status = row.status === "fresh" ? null : "fresh";
289
+ if (!restyled) return { ...base, state: "current", status };
290
+ if (kind !== "file" && !options.refreshStaleFolders) return { ...base, state: "lazy", status, restyle: true };
291
+ return { ...base, state: childrenSettled ? "ready" : "waiting", status, restyle: true };
292
+ }
235
293
 
236
294
  const hasText = Boolean(row?.tagline);
237
295
  const status: StoredSummaryStatus = hasText ? "stale" : "pending";
238
296
  const keep = (state: TargetState): PlannedTarget => ({ ...base, state, status: row?.status === status ? null : status });
239
297
 
240
298
  if (kind !== "file" && hasText && !options.refreshStaleFolders) return keep("lazy");
299
+ // left to be opened (Q2 B, PlanOptions.olderFoldersWaitToBeOpened): only the target a plan restyles goes now
300
+ if (kind !== "file" && hasText && options.olderFoldersWaitToBeOpened && !restyled && row && writtenInOlderStyle(row)) return keep("lazy");
241
301
  return keep(childrenSettled ? "ready" : "waiting");
242
302
  }
243
303
 
@@ -12,7 +12,7 @@ import { DEFAULT_SUMMARY_MODELS, type ResolvedSummaryModels } from "./models";
12
12
  import { planSummaries, writtenByModel, type PlannedTarget } from "./plan";
13
13
  import { formatUsd, pricePerMTok, type PriceTable } from "./pricing";
14
14
  import {
15
- FILE_SYSTEM_PROMPT, FOLDER_SYSTEM_PROMPT, importerLine, MAX_FOLDER_ITEMS, plural, PROMPT_VERSION, REPO_SYSTEM_PROMPT,
15
+ FILE_SYSTEM_PROMPT, FOLDER_SYSTEM_PROMPT, importerLine, joinParagraphs, MAX_FOLDER_ITEMS, plural, PROMPT_VERSION, REPO_SYSTEM_PROMPT,
16
16
  } from "./prompts";
17
17
  import { buildFilePrompt, requireStructureReady } from "./run";
18
18
  import type { Summarizer, SummaryOutcome } from "./types";
@@ -95,10 +95,9 @@ export function nextWave(plan: readonly PlannedTarget[], scope: SampleScope, sen
95
95
  }
96
96
 
97
97
  /**
98
- * An answer as the row the real run would write for it (writeResults): its text and model, the planned inputHash, and
99
- * status "fresh". The planner reads only the hash, the batch id and the text, so it now calls the target current and
100
- * shows this text in its folder's prompt. A row the index already had keeps its id; a new one has none (0), since
101
- * it's never written. Pure.
98
+ * An answer as the row the real run would write for it (writeResults): its text and model, the current prompt version,
99
+ * the planned inputHash, and status "fresh". The planner then calls the target current and shows this text in its
100
+ * folder's prompt. A row the index already had keeps its id; a new one has none (0), since it's never written. Pure.
102
101
  */
103
102
  export function answeredRow(target: PlannedTarget, outcome: Extract<SummaryOutcome, { ok: true }>, previous: StoredSummary | undefined): StoredSummary {
104
103
  return {
@@ -109,6 +108,7 @@ export function answeredRow(target: PlannedTarget, outcome: Extract<SummaryOutco
109
108
  summary: outcome.text.summary,
110
109
  model: outcome.model,
111
110
  inputHash: target.inputHash,
111
+ promptVersion: PROMPT_VERSION,
112
112
  status: "fresh",
113
113
  batchId: null,
114
114
  error: null,
@@ -175,8 +175,11 @@ export async function runFolderPreview(input: PreviewInput): Promise<FolderPrevi
175
175
  };
176
176
 
177
177
  for (;;){
178
- // refreshStaleFolders: true, as in a full run (npm run summarize): an out-of-date folder is redone, not left lazy
179
- const plan = planSummaries(input.files, input.folders, [...rows.values()], { models: setting.models, refreshStaleFolders: true, failedThisRun });
178
+ // refreshStaleFolders: true and restyle: "all", as in a full run (npm run summarize): an out-of-date folder is
179
+ // redone, not left lazy, and so is a summary an older prompt version wrote
180
+ const plan = planSummaries(input.files, input.folders, [...rows.values()], {
181
+ models: setting.models, refreshStaleFolders: true, failedThisRun, restyle: "all",
182
+ });
180
183
  const wave = nextWave(plan, scope, sent);
181
184
 
182
185
  if (wave.length === 0){
@@ -433,10 +436,10 @@ function shown(preview: FolderPreview, kind: SummaryTargetKind, targetPath: stri
433
436
  if (!outcome.ok) return `failed: ${outcome.error}`;
434
437
  // a server-side fallback's answer isn't the summary the model asked would have written, and the reader should know
435
438
  const by = outcome.model === target?.model ? "" : ` (answered by ${outcome.model})`;
436
- return `*${outcome.text.tagline}* — ${outcome.text.summary}${by}`;
439
+ return `*${outcome.text.tagline}* — ${joinParagraphs(outcome.text.summary)}${by}`;
437
440
  }
438
441
 
439
442
  const row = preview.rows.get(key);
440
- if (target?.state === "current" && row?.tagline) return `*${row.tagline}* — ${row.summary} (already in the index, not sent again)`;
443
+ if (target?.state === "current" && row?.tagline) return `*${row.tagline}* — ${joinParagraphs(row.summary ?? "")} (already in the index, not sent again)`;
441
444
  return `not summarized (${target?.state ?? "not planned"})`;
442
445
  }