@remnic/core 9.69.53 → 9.69.55

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 (102) hide show
  1. package/dist/access-admin-ops-surface.js +2 -2
  2. package/dist/access-authorization-probe.js +3 -3
  3. package/dist/access-boundary.js +2 -2
  4. package/dist/access-cli.js +24 -24
  5. package/dist/access-extraction-force-flush.js +2 -2
  6. package/dist/access-http-briefing.js +3 -3
  7. package/dist/access-http-query.js +2 -2
  8. package/dist/access-http.js +14 -14
  9. package/dist/access-identity-continuity-surface.js +2 -2
  10. package/dist/access-lcm-surface.js +2 -2
  11. package/dist/access-mcp.js +9 -9
  12. package/dist/access-namespace-preflight.js +2 -2
  13. package/dist/access-observe-write-surface.js +2 -2
  14. package/dist/access-operations-batch.js +3 -3
  15. package/dist/access-operations.js +8 -8
  16. package/dist/access-recall-concurrency.js +2 -2
  17. package/dist/access-recall-response.js +2 -2
  18. package/dist/access-recall-surface.js +2 -2
  19. package/dist/access-service.js +2 -2
  20. package/dist/access-surface-catalog.js +4 -4
  21. package/dist/active-recall.js +2 -2
  22. package/dist/{chunk-ZADYEZY4.js → chunk-2SIKTXWX.js} +2 -2
  23. package/dist/{chunk-TS66MUUJ.js → chunk-3R23DDD3.js} +8 -8
  24. package/dist/{chunk-EQNZC3BH.js → chunk-3S6URXEH.js} +2 -2
  25. package/dist/{chunk-BCXZ4BDS.js → chunk-75YZ5VAI.js} +2 -2
  26. package/dist/{chunk-MXTCV3B5.js → chunk-AHVJ6GLL.js} +2 -2
  27. package/dist/{chunk-6UEM5EOB.js → chunk-AQ55YKEV.js} +2 -2
  28. package/dist/{chunk-VMKAA5SU.js → chunk-BSCNBIF4.js} +2 -2
  29. package/dist/{chunk-M7HFDWOS.js → chunk-FROKVQQY.js} +2 -2
  30. package/dist/{chunk-A4TQFNRX.js → chunk-GRDRVYXJ.js} +3 -3
  31. package/dist/{chunk-EYK3UHAP.js → chunk-H6WOFMOV.js} +2 -2
  32. package/dist/{chunk-SOOWBRMH.js → chunk-IYNCJKBM.js} +8 -8
  33. package/dist/{chunk-3KJTSCSX.js → chunk-J54B5DGD.js} +5 -4
  34. package/dist/chunk-J54B5DGD.js.map +1 -0
  35. package/dist/{chunk-UVZBDIJX.js → chunk-KIGC55FQ.js} +489 -49
  36. package/dist/chunk-KIGC55FQ.js.map +1 -0
  37. package/dist/{chunk-KQQEZTH6.js → chunk-KQVBR7BC.js} +2 -2
  38. package/dist/{chunk-MTJUIIM4.js → chunk-MTWD4KMM.js} +2 -2
  39. package/dist/{chunk-JE4H2QBO.js → chunk-NNEWBRUL.js} +2 -2
  40. package/dist/{chunk-SEQ6DWXD.js → chunk-NPX6WMFJ.js} +2 -2
  41. package/dist/{chunk-Z22M4JCY.js → chunk-NZLFVLLJ.js} +165 -5
  42. package/dist/chunk-NZLFVLLJ.js.map +1 -0
  43. package/dist/{chunk-QKCXRS5A.js → chunk-OGXXGHW6.js} +11 -1
  44. package/dist/{chunk-QKCXRS5A.js.map → chunk-OGXXGHW6.js.map} +1 -1
  45. package/dist/{chunk-CO7KBRPF.js → chunk-TJNDFNCU.js} +2 -2
  46. package/dist/{chunk-K7FXR75S.js → chunk-XZRZJHBW.js} +361 -579
  47. package/dist/chunk-XZRZJHBW.js.map +1 -0
  48. package/dist/{chunk-HUQMVI3A.js → chunk-ZBYVR5W4.js} +2 -2
  49. package/dist/{chunk-ZANEMAEV.js → chunk-ZP73EM2X.js} +2 -2
  50. package/dist/{chunk-IEOBAOA6.js → chunk-ZXQPAYCS.js} +2 -2
  51. package/dist/cli.js +19 -19
  52. package/dist/compounding/engine.js +3 -3
  53. package/dist/config.js +2 -2
  54. package/dist/external-wiki-access.js +3 -3
  55. package/dist/index.d.ts +139 -41
  56. package/dist/index.js +36 -26
  57. package/dist/memory-browse-operations.js +3 -3
  58. package/dist/operator-toolkit.js +3 -3
  59. package/dist/orchestration/maintenance.js +3 -2
  60. package/dist/orchestrator.js +24 -24
  61. package/dist/recall-navigation-operations.js +3 -3
  62. package/dist/recall-navigation.js +2 -2
  63. package/dist/resume-bundles.js +3 -3
  64. package/dist/shared-context/manager.d.ts +16 -0
  65. package/dist/shared-context/manager.js +2 -2
  66. package/dist/support-passport/index.js +5 -5
  67. package/package.json +2 -2
  68. package/src/activity/config.test.ts +19 -0
  69. package/src/activity/config.ts +3 -4
  70. package/src/activity/index.ts +2 -0
  71. package/src/activity/timeline/index.ts +1 -0
  72. package/src/activity/timeline/week-dashboard.test.ts +362 -0
  73. package/src/activity/timeline/week-dashboard.ts +273 -0
  74. package/src/activity/timeline/weekly.ts +2 -1
  75. package/src/lifecycle/seed-graduation.test.ts +209 -0
  76. package/src/lifecycle/seed-graduation.ts +94 -11
  77. package/src/shared-context/governance-wiring.test.ts +153 -0
  78. package/src/shared-context/manager.ts +104 -3
  79. package/src/shared-context/staleness.ts +1 -1
  80. package/dist/chunk-3KJTSCSX.js.map +0 -1
  81. package/dist/chunk-K7FXR75S.js.map +0 -1
  82. package/dist/chunk-UVZBDIJX.js.map +0 -1
  83. package/dist/chunk-Z22M4JCY.js.map +0 -1
  84. /package/dist/{chunk-ZADYEZY4.js.map → chunk-2SIKTXWX.js.map} +0 -0
  85. /package/dist/{chunk-TS66MUUJ.js.map → chunk-3R23DDD3.js.map} +0 -0
  86. /package/dist/{chunk-EQNZC3BH.js.map → chunk-3S6URXEH.js.map} +0 -0
  87. /package/dist/{chunk-BCXZ4BDS.js.map → chunk-75YZ5VAI.js.map} +0 -0
  88. /package/dist/{chunk-MXTCV3B5.js.map → chunk-AHVJ6GLL.js.map} +0 -0
  89. /package/dist/{chunk-6UEM5EOB.js.map → chunk-AQ55YKEV.js.map} +0 -0
  90. /package/dist/{chunk-VMKAA5SU.js.map → chunk-BSCNBIF4.js.map} +0 -0
  91. /package/dist/{chunk-M7HFDWOS.js.map → chunk-FROKVQQY.js.map} +0 -0
  92. /package/dist/{chunk-A4TQFNRX.js.map → chunk-GRDRVYXJ.js.map} +0 -0
  93. /package/dist/{chunk-EYK3UHAP.js.map → chunk-H6WOFMOV.js.map} +0 -0
  94. /package/dist/{chunk-SOOWBRMH.js.map → chunk-IYNCJKBM.js.map} +0 -0
  95. /package/dist/{chunk-KQQEZTH6.js.map → chunk-KQVBR7BC.js.map} +0 -0
  96. /package/dist/{chunk-MTJUIIM4.js.map → chunk-MTWD4KMM.js.map} +0 -0
  97. /package/dist/{chunk-JE4H2QBO.js.map → chunk-NNEWBRUL.js.map} +0 -0
  98. /package/dist/{chunk-SEQ6DWXD.js.map → chunk-NPX6WMFJ.js.map} +0 -0
  99. /package/dist/{chunk-CO7KBRPF.js.map → chunk-TJNDFNCU.js.map} +0 -0
  100. /package/dist/{chunk-HUQMVI3A.js.map → chunk-ZBYVR5W4.js.map} +0 -0
  101. /package/dist/{chunk-ZANEMAEV.js.map → chunk-ZP73EM2X.js.map} +0 -0
  102. /package/dist/{chunk-IEOBAOA6.js.map → chunk-ZXQPAYCS.js.map} +0 -0
@@ -0,0 +1,362 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mkdtempSync, readFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+
7
+ import { planActivityDeletion } from "../privacy-delete-plan.js";
8
+ import type { TimelineCard, TimelineCategory } from "./types.js";
9
+ import {
10
+ DEFAULT_TOP_SOURCE_LIMIT,
11
+ WEEKLY_DASHBOARD_FORMAT_VERSION,
12
+ buildWeeklyDashboard,
13
+ type WeekSourceAttribution,
14
+ } from "./week-dashboard.js";
15
+ import { persistWeeklySnapshot } from "./weekly-persist.js";
16
+
17
+ const HOUR = 3_600_000;
18
+ const WEEK_START = "2026-07-13T00:00:00.000Z";
19
+ const WEEK_END = "2026-07-20T00:00:00.000Z";
20
+ const PREV_WEEK_START = "2026-07-06T00:00:00.000Z";
21
+
22
+ const CATEGORIES: TimelineCategory[] = [
23
+ { id: "development", name: "Development", color: "#111111", description: "d", order: 1 },
24
+ { id: "communication", name: "Communication", color: "#222222", description: "c", order: 2 },
25
+ ];
26
+
27
+ function card(
28
+ overrides: Partial<TimelineCard> & Pick<TimelineCard, "id" | "startUtc" | "endUtc">,
29
+ ): TimelineCard {
30
+ return {
31
+ kind: "activity",
32
+ title: "Untitled",
33
+ summary: "none",
34
+ categoryId: "development",
35
+ confidence: 1,
36
+ dayKey: overrides.startUtc.slice(0, 10),
37
+ timezone: "UTC",
38
+ machine: "ws-a",
39
+ evidenceIds: [],
40
+ evidenceRange: null,
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ function options(overrides: Partial<Parameters<typeof buildWeeklyDashboard>[1]> = {}) {
46
+ return {
47
+ timezone: "UTC",
48
+ weekStartUtc: WEEK_START,
49
+ weekEndUtc: WEEK_END,
50
+ categories: CATEGORIES,
51
+ ...overrides,
52
+ };
53
+ }
54
+
55
+ function attributions(...entries: Array<[string, WeekSourceAttribution]>): Map<string, WeekSourceAttribution> {
56
+ return new Map(entries);
57
+ }
58
+
59
+ test("empty week yields zero sources, no recurring patterns, zero evidence, unavailable previous period", () => {
60
+ const dashboard = buildWeeklyDashboard([], options());
61
+ assert.equal(dashboard.dashboardFormatVersion, WEEKLY_DASHBOARD_FORMAT_VERSION);
62
+ assert.deepEqual(dashboard.sources, { applications: [], domains: [] });
63
+ assert.deepEqual(dashboard.recurring, { categories: [], applications: [] });
64
+ assert.deepEqual(dashboard.evidence, {
65
+ activityCardCount: 0,
66
+ idleCardCount: 0,
67
+ pauseCardCount: 0,
68
+ evidenceCount: 0,
69
+ distinctEvidenceCount: 0,
70
+ });
71
+ assert.deepEqual(dashboard.uncertainty, {
72
+ clippedCardCount: 0,
73
+ manualEditCount: 0,
74
+ unclassifiedCardCount: 0,
75
+ });
76
+ assert.deepEqual(dashboard.previousPeriod, { available: false });
77
+ });
78
+
79
+ test("top applications and domains aggregate clipped durations; equal totals tie-break by key", () => {
80
+ const cards = [
81
+ card({ id: "a", startUtc: "2026-07-13T10:00:00.000Z", endUtc: "2026-07-13T11:00:00.000Z" }),
82
+ card({ id: "b", startUtc: "2026-07-13T11:00:00.000Z", endUtc: "2026-07-13T12:00:00.000Z" }),
83
+ card({ id: "c", startUtc: "2026-07-15T09:00:00.000Z", endUtc: "2026-07-15T10:00:00.000Z" }),
84
+ card({ id: "d", startUtc: "2026-07-17T09:00:00.000Z", endUtc: "2026-07-17T10:00:00.000Z" }),
85
+ card({ id: "e", startUtc: "2026-07-17T10:00:00.000Z", endUtc: "2026-07-17T11:00:00.000Z" }),
86
+ ];
87
+ const dashboard = buildWeeklyDashboard(cards, {
88
+ ...options(),
89
+ attributions: attributions(
90
+ ["a", { app: " Code " }],
91
+ ["b", { app: "Chrome", domain: "GitHub.com" }],
92
+ ["c", { app: "Chrome", domain: "github.com" }],
93
+ ["d", { app: "Zed" }],
94
+ ["e", { app: "Alpha" }],
95
+ ),
96
+ });
97
+ // Chrome 2h, Code 1h; Zed and Alpha tie at 1h and sort by key ascending.
98
+ assert.deepEqual(dashboard.sources.applications, [
99
+ { key: "Chrome", durationMs: 2 * HOUR },
100
+ { key: "Alpha", durationMs: HOUR },
101
+ { key: "Code", durationMs: HOUR },
102
+ { key: "Zed", durationMs: HOUR },
103
+ ]);
104
+ // Domains are normalized to lowercase; only attributed browser time counts.
105
+ assert.deepEqual(dashboard.sources.domains, [{ key: "github.com", durationMs: 2 * HOUR }]);
106
+ });
107
+
108
+ test("a card crossing a week bound is clipped once and flagged as uncertain coverage", () => {
109
+ const cards = [
110
+ card({
111
+ id: "spills-before",
112
+ startUtc: "2026-07-12T23:00:00.000Z",
113
+ endUtc: "2026-07-13T01:00:00.000Z",
114
+ }),
115
+ ];
116
+ const dashboard = buildWeeklyDashboard(cards, {
117
+ ...options(),
118
+ attributions: attributions(["spills-before", { app: "Code" }]),
119
+ });
120
+ assert.equal(dashboard.sources.applications[0]?.durationMs, HOUR);
121
+ assert.equal(dashboard.uncertainty.clippedCardCount, 1);
122
+ // The in-week part lands on Monday only; Sunday belongs to the prior week.
123
+ assert.equal(dashboard.days.find((day) => day.date === "2026-07-13")?.activeMs, HOUR);
124
+ });
125
+
126
+ test("topSourceLimit caps each source list; default limit is 10", () => {
127
+ assert.equal(DEFAULT_TOP_SOURCE_LIMIT, 10);
128
+ const cards = [
129
+ card({ id: "a", startUtc: "2026-07-13T10:00:00.000Z", endUtc: "2026-07-13T11:00:00.000Z" }),
130
+ card({ id: "b", startUtc: "2026-07-14T10:00:00.000Z", endUtc: "2026-07-14T11:00:00.000Z" }),
131
+ ];
132
+ const dashboard = buildWeeklyDashboard(cards, {
133
+ ...options(),
134
+ topSourceLimit: 1,
135
+ attributions: attributions(["a", { app: "Beta" }], ["b", { app: "Alpha" }]),
136
+ });
137
+ assert.deepEqual(dashboard.sources.applications, [{ key: "Alpha", durationMs: HOUR }]);
138
+ });
139
+
140
+ test("recurring patterns gate on distinct days for categories and applications", () => {
141
+ const cards = [
142
+ // development on Mon/Tue/Wed -> recurring.
143
+ card({ id: "d1", startUtc: "2026-07-13T10:00:00.000Z", endUtc: "2026-07-13T11:00:00.000Z" }),
144
+ card({ id: "d2", startUtc: "2026-07-14T10:00:00.000Z", endUtc: "2026-07-14T11:00:00.000Z" }),
145
+ card({ id: "d3", startUtc: "2026-07-15T10:00:00.000Z", endUtc: "2026-07-15T11:00:00.000Z" }),
146
+ // communication on Mon/Tue only -> not recurring.
147
+ card({
148
+ id: "c1",
149
+ categoryId: "communication",
150
+ startUtc: "2026-07-13T12:00:00.000Z",
151
+ endUtc: "2026-07-13T13:00:00.000Z",
152
+ }),
153
+ card({
154
+ id: "c2",
155
+ categoryId: "communication",
156
+ startUtc: "2026-07-14T12:00:00.000Z",
157
+ endUtc: "2026-07-14T13:00:00.000Z",
158
+ }),
159
+ ];
160
+ const dashboard = buildWeeklyDashboard(cards, {
161
+ ...options(),
162
+ attributions: attributions(
163
+ ["d1", { app: "Code" }],
164
+ ["d2", { app: "Code" }],
165
+ ["d3", { app: "Code" }],
166
+ ["c1", { app: "Chat" }],
167
+ ["c2", { app: "Chat" }],
168
+ ),
169
+ });
170
+ assert.deepEqual(dashboard.recurring.categories, [
171
+ { key: "development", dayCount: 3, totalDurationMs: 3 * HOUR },
172
+ ]);
173
+ assert.deepEqual(dashboard.recurring.applications, [
174
+ { key: "Code", dayCount: 3, totalDurationMs: 3 * HOUR },
175
+ ]);
176
+ });
177
+
178
+ test("previous week cards produce explicit deltas; absent stays explicitly unavailable", () => {
179
+ const current = [
180
+ card({ id: "now", startUtc: "2026-07-13T10:00:00.000Z", endUtc: "2026-07-13T12:00:00.000Z" }),
181
+ ];
182
+ const previous = [
183
+ card({ id: "then", startUtc: "2026-07-06T10:00:00.000Z", endUtc: "2026-07-06T13:00:00.000Z" }),
184
+ ];
185
+ const compared = buildWeeklyDashboard(current, { ...options(), previousWeekCards: previous });
186
+ assert.equal(compared.previousPeriod.available, true);
187
+ if (compared.previousPeriod.available) {
188
+ assert.equal(compared.previousPeriod.previousStartUtc, PREV_WEEK_START);
189
+ assert.equal(compared.previousPeriod.previousEndUtc, WEEK_START);
190
+ assert.equal(compared.previousPeriod.deltaActiveMs, -HOUR);
191
+ assert.equal(compared.previousPeriod.deltaIdleMs, 0);
192
+ }
193
+ const first = buildWeeklyDashboard(current, options());
194
+ assert.deepEqual(first.previousPeriod, { available: false });
195
+ });
196
+
197
+ test("evidence counts and uncertainty flags reflect only in-week activity", () => {
198
+ const cards = [
199
+ card({
200
+ id: "plain",
201
+ startUtc: "2026-07-13T10:00:00.000Z",
202
+ endUtc: "2026-07-13T11:00:00.000Z",
203
+ evidenceIds: [1, 2],
204
+ }),
205
+ card({
206
+ id: "repeat",
207
+ startUtc: "2026-07-14T10:00:00.000Z",
208
+ endUtc: "2026-07-14T11:00:00.000Z",
209
+ evidenceIds: [2, 3],
210
+ }),
211
+ card({
212
+ id: "edited",
213
+ startUtc: "2026-07-15T10:00:00.000Z",
214
+ endUtc: "2026-07-15T11:00:00.000Z",
215
+ manualEdit: { editedAtUtc: "2026-07-15T12:00:00.000Z" },
216
+ }),
217
+ card({
218
+ id: "mystery",
219
+ categoryId: "system.unknown",
220
+ confidence: 0,
221
+ startUtc: "2026-07-16T10:00:00.000Z",
222
+ endUtc: "2026-07-16T11:00:00.000Z",
223
+ }),
224
+ card({
225
+ id: "idle",
226
+ kind: "idle",
227
+ categoryId: "system.idle",
228
+ startUtc: "2026-07-13T15:00:00.000Z",
229
+ endUtc: "2026-07-13T16:00:00.000Z",
230
+ }),
231
+ card({
232
+ id: "pause",
233
+ kind: "pause",
234
+ categoryId: "system.pause",
235
+ startUtc: "2026-07-13T16:00:00.000Z",
236
+ endUtc: "2026-07-13T17:00:00.000Z",
237
+ }),
238
+ // Fully outside the week: counted nowhere.
239
+ card({ id: "outside", startUtc: "2026-08-01T10:00:00.000Z", endUtc: "2026-08-01T11:00:00.000Z" }),
240
+ ];
241
+ const dashboard = buildWeeklyDashboard(cards, options());
242
+ assert.deepEqual(dashboard.evidence, {
243
+ activityCardCount: 4,
244
+ idleCardCount: 1,
245
+ pauseCardCount: 1,
246
+ evidenceCount: 4,
247
+ distinctEvidenceCount: 3,
248
+ });
249
+ assert.deepEqual(dashboard.uncertainty, {
250
+ clippedCardCount: 0,
251
+ manualEditCount: 1,
252
+ unclassifiedCardCount: 1,
253
+ });
254
+ });
255
+
256
+ test("a deleted category flows to unclassified and never recurs as a pattern", () => {
257
+ const cards = [
258
+ card({ id: "g1", categoryId: "gone", startUtc: "2026-07-13T10:00:00.000Z", endUtc: "2026-07-13T11:00:00.000Z" }),
259
+ card({ id: "g2", categoryId: "gone", startUtc: "2026-07-14T10:00:00.000Z", endUtc: "2026-07-14T11:00:00.000Z" }),
260
+ card({ id: "g3", categoryId: "gone", startUtc: "2026-07-15T10:00:00.000Z", endUtc: "2026-07-15T11:00:00.000Z" }),
261
+ ];
262
+ const dashboard = buildWeeklyDashboard(cards, options());
263
+ assert.equal(dashboard.unclassifiedMs, 3 * HOUR);
264
+ assert.deepEqual(dashboard.recurring.categories, []);
265
+ assert.ok(dashboard.categories.every((total) => total.categoryId !== "gone"));
266
+ });
267
+
268
+ test("card input order never changes the serialized snapshot bytes", () => {
269
+ const cards = [
270
+ card({ id: "a", startUtc: "2026-07-13T10:00:00.000Z", endUtc: "2026-07-13T11:00:00.000Z" }),
271
+ card({ id: "b", startUtc: "2026-07-14T10:00:00.000Z", endUtc: "2026-07-14T11:00:00.000Z" }),
272
+ card({ id: "c", startUtc: "2026-07-15T10:00:00.000Z", endUtc: "2026-07-15T11:00:00.000Z" }),
273
+ ];
274
+ const base = {
275
+ ...options(),
276
+ attributions: attributions(
277
+ ["a", { app: "Code", domain: "github.com" }],
278
+ ["b", { app: "Chat" }],
279
+ ["c", { app: "Code", domain: "github.com" }],
280
+ ),
281
+ };
282
+ const forward = JSON.stringify(buildWeeklyDashboard(cards, base));
283
+ const reversed = JSON.stringify(buildWeeklyDashboard([...cards].reverse(), base));
284
+ assert.equal(forward, reversed);
285
+ });
286
+
287
+ test("dashboard persists through the weekly snapshot store idempotently", () => {
288
+ const memoryDir = mkdtempSync(path.join(tmpdir(), "remnic-week-dashboard-"));
289
+ const cards = [
290
+ card({ id: "a", startUtc: "2026-07-13T10:00:00.000Z", endUtc: "2026-07-13T11:00:00.000Z" }),
291
+ ];
292
+ const dashboard = buildWeeklyDashboard(cards, {
293
+ ...options(),
294
+ attributions: attributions(["a", { app: "Code", domain: "github.com" }]),
295
+ });
296
+ const shared = {
297
+ memoryDir,
298
+ namespace: "dash-1",
299
+ summary: dashboard,
300
+ sourceRevision: "rev-1",
301
+ configHash: "cfg-1",
302
+ };
303
+ const first = persistWeeklySnapshot(shared);
304
+ assert.equal(first.written, true);
305
+ assert.ok(first.path.includes(path.join("activity", "weekly")));
306
+ const bytes = readFileSync(first.path, "utf8");
307
+ const parsed = JSON.parse(bytes);
308
+ assert.equal(parsed.namespace, "dash-1");
309
+ assert.equal(parsed.summary.sources.domains[0].key, "github.com");
310
+
311
+ const again = persistWeeklySnapshot(shared);
312
+ assert.equal(again.written, false);
313
+ assert.equal(again.path, first.path);
314
+ assert.equal(readFileSync(first.path, "utf8"), bytes);
315
+
316
+ const reconfigured = persistWeeklySnapshot({ ...shared, configHash: "cfg-2" });
317
+ assert.equal(reconfigured.written, true);
318
+ assert.notEqual(reconfigured.path, first.path);
319
+ assert.equal(readFileSync(first.path, "utf8"), bytes);
320
+ });
321
+
322
+ test("retention plans expired weekly snapshots for deletion and keeps fresh ones", () => {
323
+ const now = Date.parse("2026-07-20T00:00:00.000Z");
324
+ const DAY = 86_400_000;
325
+ const expired = now - 40 * DAY;
326
+ const plan = planActivityDeletion({
327
+ candidates: [
328
+ { scope: "weekly", relPath: "activity/weekly/dash-1/2026-06-08--abc.json", capturedAtMs: expired },
329
+ { scope: "weekly", relPath: "activity/weekly/dash-1/2026-07-13--def.json", capturedAtMs: now - DAY },
330
+ ],
331
+ scopes: ["weekly"],
332
+ retentionDays: 30,
333
+ nowMs: now,
334
+ });
335
+ assert.deepEqual(plan.deletePaths, ["activity/weekly/dash-1/2026-06-08--abc.json"]);
336
+ });
337
+
338
+ test("invalid inputs throw RangeError without partial output", () => {
339
+ const cards = [
340
+ card({ id: "a", startUtc: "2026-07-13T10:00:00.000Z", endUtc: "2026-07-13T11:00:00.000Z" }),
341
+ ];
342
+ assert.throws(
343
+ () => buildWeeklyDashboard(cards, { ...options(), topSourceLimit: 0 }),
344
+ (err: unknown) => err instanceof RangeError && /topSourceLimit/.test((err as Error).message),
345
+ );
346
+ assert.throws(
347
+ () =>
348
+ buildWeeklyDashboard(cards, {
349
+ ...options(),
350
+ attributions: attributions(["a", { app: " " }]),
351
+ }),
352
+ (err: unknown) => err instanceof RangeError && /app/.test((err as Error).message),
353
+ );
354
+ assert.throws(
355
+ () =>
356
+ buildWeeklyDashboard(cards, {
357
+ ...options(),
358
+ attributions: attributions(["a", { app: "Code", domain: "" }]),
359
+ }),
360
+ (err: unknown) => err instanceof RangeError && /domain/.test((err as Error).message),
361
+ );
362
+ });
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Weekly dashboard snapshot composition (issue #2052).
3
+ *
4
+ * Composes the #2052 helpers into one machine-readable week document:
5
+ * duration/category/day totals (`weekly.ts`), an explicit previous-period
6
+ * comparison (`week-previous.ts`), threshold-gated recurrence
7
+ * (`week-recurring.ts`), top application/domain summaries, source
8
+ * card/evidence counts, and uncertainty flags. Pure: no I/O, no clock, no
9
+ * LLM. This is a time/activity summary, never a score; it makes no claim
10
+ * about mood, productivity, intent, presence, or quality of work.
11
+ *
12
+ * The result extends `WeeklyActivitySummary`, so `persistWeeklySnapshot`
13
+ * accepts it unchanged: identical inputs rewrite nothing, and a new
14
+ * sourceRevision or configHash keys a new snapshot file instead of mutating
15
+ * an unrelated period. Application/domain attribution arrives as structured
16
+ * per-card metadata (from the observations that built the cards), never
17
+ * parsed back out of card prose.
18
+ */
19
+ import type { TimelineCard } from "./types.js";
20
+ import { compareWeeklyPreviousPeriod, type WeeklyPreviousPeriod } from "./week-previous.js";
21
+ import {
22
+ findRecurringPatterns,
23
+ type RecurringPattern,
24
+ type WeekDayOccurrence,
25
+ } from "./week-recurring.js";
26
+ import {
27
+ buildWeeklyActivitySummary,
28
+ type WeeklyActivityOptions,
29
+ type WeeklyActivitySummary,
30
+ } from "./weekly.js";
31
+
32
+ export const WEEKLY_DASHBOARD_FORMAT_VERSION = 1;
33
+ export const DEFAULT_TOP_SOURCE_LIMIT = 10;
34
+ const DAY_MS = 86_400_000;
35
+ const WEEK_DAYS_MS = 7 * DAY_MS;
36
+
37
+ /** Structured activity metadata for one card, keyed by card id. */
38
+ export interface WeekSourceAttribution {
39
+ /** Application name from the card's source observations. */
40
+ app: string;
41
+ /** Lowercased browser hostname, when the frontmost window was a browser. */
42
+ domain?: string;
43
+ }
44
+
45
+ export interface WeeklySourceTotal {
46
+ key: string;
47
+ durationMs: number;
48
+ }
49
+
50
+ export interface WeeklyEvidenceStats {
51
+ activityCardCount: number;
52
+ idleCardCount: number;
53
+ pauseCardCount: number;
54
+ /** Evidence ids summed across in-week activity cards; repeats possible. */
55
+ evidenceCount: number;
56
+ /** Distinct evidence ids across in-week activity cards. */
57
+ distinctEvidenceCount: number;
58
+ }
59
+
60
+ export interface WeeklyUncertainty {
61
+ /** In-week cards clipped at a week bound: the week sees only part of them. */
62
+ clippedCardCount: number;
63
+ /** In-week cards carrying a manual edit; totals may not match raw captures. */
64
+ manualEditCount: number;
65
+ /** In-week cards classified with confidence 0 (unclassified signal). */
66
+ unclassifiedCardCount: number;
67
+ }
68
+
69
+ export interface WeeklyDashboardSources {
70
+ applications: WeeklySourceTotal[];
71
+ domains: WeeklySourceTotal[];
72
+ }
73
+
74
+ export interface WeeklyDashboardRecurring {
75
+ categories: RecurringPattern[];
76
+ applications: RecurringPattern[];
77
+ }
78
+
79
+ export interface WeeklyDashboard extends WeeklyActivitySummary {
80
+ dashboardFormatVersion: number;
81
+ sources: WeeklyDashboardSources;
82
+ recurring: WeeklyDashboardRecurring;
83
+ evidence: WeeklyEvidenceStats;
84
+ uncertainty: WeeklyUncertainty;
85
+ }
86
+
87
+ export interface WeeklyDashboardOptions extends WeeklyActivityOptions {
88
+ /** Per-card application/domain metadata; cards without an entry are skipped in `sources`. */
89
+ attributions?: ReadonlyMap<string, WeekSourceAttribution>;
90
+ /** Cards of the immediately preceding week; absent keeps `previousPeriod` explicitly unavailable. */
91
+ previousWeekCards?: readonly TimelineCard[];
92
+ /** Distinct-day threshold passed to `findRecurringPatterns`. */
93
+ recurrenceMinDays?: number;
94
+ /** Max entries per source list; >= 1. */
95
+ topSourceLimit?: number;
96
+ }
97
+
98
+ interface ClippedCard {
99
+ card: TimelineCard;
100
+ startMs: number;
101
+ endMs: number;
102
+ clipStartMs: number;
103
+ clipEndMs: number;
104
+ }
105
+
106
+ function clippedInWeek(
107
+ cards: readonly TimelineCard[],
108
+ weekStartMs: number,
109
+ weekEndMs: number,
110
+ ): ClippedCard[] {
111
+ const clipped: ClippedCard[] = [];
112
+ for (const card of cards) {
113
+ const startMs = Date.parse(card.startUtc);
114
+ const endMs = Date.parse(card.endUtc);
115
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) continue;
116
+ const clipStartMs = Math.max(startMs, weekStartMs);
117
+ const clipEndMs = Math.min(endMs, weekEndMs);
118
+ if (clipEndMs > clipStartMs) {
119
+ clipped.push({ card, startMs, endMs, clipStartMs, clipEndMs });
120
+ }
121
+ }
122
+ return clipped;
123
+ }
124
+
125
+ function normalizedAttribution(
126
+ cardId: string,
127
+ attributions: ReadonlyMap<string, WeekSourceAttribution> | undefined,
128
+ ): { app: string; domain?: string } | null {
129
+ const attribution = attributions?.get(cardId);
130
+ if (attribution === undefined) return null;
131
+ if (typeof attribution.app !== "string" || attribution.app.trim() === "") {
132
+ throw new RangeError(`attribution for card ${cardId} must have a non-blank app`);
133
+ }
134
+ const app = attribution.app.trim();
135
+ if (attribution.domain === undefined) return { app };
136
+ if (typeof attribution.domain !== "string" || attribution.domain.trim() === "") {
137
+ throw new RangeError(`attribution for card ${cardId} must have a non-blank domain when present`);
138
+ }
139
+ return { app, domain: attribution.domain.trim().toLowerCase() };
140
+ }
141
+
142
+ function rankTotals(totals: Map<string, number>, limit: number): WeeklySourceTotal[] {
143
+ return [...totals.entries()]
144
+ .map(([key, durationMs]) => ({ key, durationMs }))
145
+ .sort(
146
+ (a, b) =>
147
+ b.durationMs - a.durationMs || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0),
148
+ )
149
+ .slice(0, limit);
150
+ }
151
+
152
+ /** Build the full dashboard snapshot for one half-open week. Pure. */
153
+ export function buildWeeklyDashboard(
154
+ cards: readonly TimelineCard[],
155
+ options: WeeklyDashboardOptions,
156
+ ): WeeklyDashboard {
157
+ const summary = buildWeeklyActivitySummary(cards, options);
158
+ const topSourceLimit = options.topSourceLimit ?? DEFAULT_TOP_SOURCE_LIMIT;
159
+ if (!Number.isInteger(topSourceLimit) || topSourceLimit < 1) {
160
+ throw new RangeError(`topSourceLimit must be an integer >= 1; got ${topSourceLimit}`);
161
+ }
162
+
163
+ // Canonical bounds from the summary: identical clipping arithmetic to
164
+ // weekly.ts regardless of how the caller spelled the input timestamps.
165
+ const weekStartMs = Date.parse(summary.weekStartUtc);
166
+ const weekEndMs = Date.parse(summary.weekEndUtc);
167
+ const inWeek = clippedInWeek(cards, weekStartMs, weekEndMs);
168
+
169
+ const applicationTotals = new Map<string, number>();
170
+ const domainTotals = new Map<string, number>();
171
+ const applicationOccurrences: WeekDayOccurrence[] = [];
172
+ const categoryOccurrences: WeekDayOccurrence[] = [];
173
+ const evidence: WeeklyEvidenceStats = {
174
+ activityCardCount: 0,
175
+ idleCardCount: 0,
176
+ pauseCardCount: 0,
177
+ evidenceCount: 0,
178
+ distinctEvidenceCount: 0,
179
+ };
180
+ const uncertainty: WeeklyUncertainty = {
181
+ clippedCardCount: 0,
182
+ manualEditCount: 0,
183
+ unclassifiedCardCount: 0,
184
+ };
185
+ const distinctEvidence = new Set<number>();
186
+ const dayWindows = summary.days.map((day) => ({
187
+ date: day.date,
188
+ startMs: Date.parse(day.startUtc),
189
+ endMs: Date.parse(day.endUtc),
190
+ }));
191
+
192
+ for (const day of summary.days) {
193
+ for (const total of day.categories) {
194
+ if (total.durationMs > 0) {
195
+ categoryOccurrences.push({ date: day.date, key: total.categoryId, durationMs: total.durationMs });
196
+ }
197
+ }
198
+ }
199
+
200
+ for (const { card, startMs, endMs, clipStartMs, clipEndMs } of inWeek) {
201
+ if (startMs < weekStartMs || endMs > weekEndMs) {
202
+ uncertainty.clippedCardCount++;
203
+ }
204
+ if (card.kind === "idle") {
205
+ evidence.idleCardCount++;
206
+ continue;
207
+ }
208
+ if (card.kind === "pause") {
209
+ evidence.pauseCardCount++;
210
+ continue;
211
+ }
212
+ evidence.activityCardCount++;
213
+ for (const id of card.evidenceIds) {
214
+ evidence.evidenceCount++;
215
+ distinctEvidence.add(id);
216
+ }
217
+ if (card.manualEdit !== undefined) uncertainty.manualEditCount++;
218
+ if (card.confidence === 0) uncertainty.unclassifiedCardCount++;
219
+
220
+ const attribution = normalizedAttribution(card.id, options.attributions);
221
+ if (attribution === null) continue;
222
+ const durationMs = clipEndMs - clipStartMs;
223
+ applicationTotals.set(attribution.app, (applicationTotals.get(attribution.app) ?? 0) + durationMs);
224
+ if (attribution.domain !== undefined) {
225
+ domainTotals.set(attribution.domain, (domainTotals.get(attribution.domain) ?? 0) + durationMs);
226
+ }
227
+ for (const day of dayWindows) {
228
+ const dayStartMs = Math.max(clipStartMs, day.startMs);
229
+ const dayEndMs = Math.min(clipEndMs, day.endMs);
230
+ if (dayEndMs > dayStartMs) {
231
+ applicationOccurrences.push({ date: day.date, key: attribution.app, durationMs: dayEndMs - dayStartMs });
232
+ }
233
+ }
234
+ }
235
+ evidence.distinctEvidenceCount = distinctEvidence.size;
236
+
237
+ let previousPeriod: WeeklyPreviousPeriod = { available: false };
238
+ if (options.previousWeekCards !== undefined) {
239
+ const previousSummary = buildWeeklyActivitySummary(options.previousWeekCards, {
240
+ ...options,
241
+ weekStartUtc: new Date(weekStartMs - WEEK_DAYS_MS).toISOString(),
242
+ weekEndUtc: summary.weekStartUtc,
243
+ });
244
+ previousPeriod = compareWeeklyPreviousPeriod({
245
+ previous: previousSummary,
246
+ current: summary,
247
+ previousStartUtc: previousSummary.weekStartUtc,
248
+ previousEndUtc: previousSummary.weekEndUtc,
249
+ });
250
+ }
251
+
252
+ return {
253
+ ...summary,
254
+ previousPeriod,
255
+ dashboardFormatVersion: WEEKLY_DASHBOARD_FORMAT_VERSION,
256
+ sources: {
257
+ applications: rankTotals(applicationTotals, topSourceLimit),
258
+ domains: rankTotals(domainTotals, topSourceLimit),
259
+ },
260
+ recurring: {
261
+ categories: findRecurringPatterns({
262
+ occurrences: categoryOccurrences,
263
+ minDays: options.recurrenceMinDays,
264
+ }),
265
+ applications: findRecurringPatterns({
266
+ occurrences: applicationOccurrences,
267
+ minDays: options.recurrenceMinDays,
268
+ }),
269
+ },
270
+ evidence,
271
+ uncertainty,
272
+ };
273
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { parseFlexibleIsoTimestamp } from "../../utils/iso-timestamp.js";
10
10
  import { activityDayWindow, assertValidTimezone } from "../digest.js";
11
+ import type { WeeklyPreviousPeriod } from "./week-previous.js";
11
12
  import type { TimelineCard, TimelineCardKind, TimelineCategory } from "./types.js";
12
13
 
13
14
  export const WEEKLY_ACTIVITY_FORMAT_VERSION = 1;
@@ -51,7 +52,7 @@ export interface WeeklyActivitySummary extends WeeklyKindTotals {
51
52
  weekEndUtc: string;
52
53
  categories: WeeklyCategoryTotal[];
53
54
  days: WeeklyDayTotal[];
54
- previousPeriod: WeeklyPreviousPeriodUnavailable;
55
+ previousPeriod: WeeklyPreviousPeriod;
55
56
  }
56
57
 
57
58
  interface Acc extends WeeklyKindTotals {