@qearlyao/familiar 0.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.
- package/.env.example +31 -0
- package/HEARTBEAT.md +23 -0
- package/LICENSE +21 -0
- package/MEMORY.md +1 -0
- package/README.md +245 -0
- package/SOUL.md +13 -0
- package/USER.md +13 -0
- package/config.example.toml +221 -0
- package/dist/agent-events.js +167 -0
- package/dist/agent.js +590 -0
- package/dist/browser-tools.js +638 -0
- package/dist/chat-log.js +130 -0
- package/dist/cli.js +168 -0
- package/dist/config.js +804 -0
- package/dist/data-retention.js +54 -0
- package/dist/discord.js +1203 -0
- package/dist/generated-media.js +86 -0
- package/dist/image-derivatives.js +102 -0
- package/dist/image-gen.js +440 -0
- package/dist/inbound-attachments.js +266 -0
- package/dist/index.js +10 -0
- package/dist/media-understanding.js +120 -0
- package/dist/memory/diary/ambient-injector.js +180 -0
- package/dist/memory/diary/ambient.js +124 -0
- package/dist/memory/diary/chunks.js +231 -0
- package/dist/memory/diary/index.js +3 -0
- package/dist/memory/diary/indexer.js +93 -0
- package/dist/memory/doctor.js +250 -0
- package/dist/memory/index/chunk-indexer.js +151 -0
- package/dist/memory/index/embedding-provider.js +119 -0
- package/dist/memory/index/fts-query.js +18 -0
- package/dist/memory/index/retrieval.js +246 -0
- package/dist/memory/index/schema.js +157 -0
- package/dist/memory/index/store.js +513 -0
- package/dist/memory/index/vec.js +72 -0
- package/dist/memory/index/vector-codec.js +27 -0
- package/dist/memory/lcm/backfill.js +247 -0
- package/dist/memory/lcm/condense.js +146 -0
- package/dist/memory/lcm/context-transformer.js +662 -0
- package/dist/memory/lcm/context.js +421 -0
- package/dist/memory/lcm/eviction-score.js +38 -0
- package/dist/memory/lcm/index.js +6 -0
- package/dist/memory/lcm/indexer.js +200 -0
- package/dist/memory/lcm/normalize.js +235 -0
- package/dist/memory/lcm/schema.js +188 -0
- package/dist/memory/lcm/segment-manager.js +136 -0
- package/dist/memory/lcm/store.js +722 -0
- package/dist/memory/lcm/summarizer.js +258 -0
- package/dist/memory/lcm/types.js +1 -0
- package/dist/memory/operator.js +477 -0
- package/dist/memory/service.js +202 -0
- package/dist/memory/tools.js +205 -0
- package/dist/models.js +165 -0
- package/dist/persona.js +54 -0
- package/dist/runtime.js +493 -0
- package/dist/scheduler.js +200 -0
- package/dist/settings.js +116 -0
- package/dist/skills.js +38 -0
- package/dist/tts.js +143 -0
- package/dist/web-auth.js +105 -0
- package/dist/web-events.js +114 -0
- package/dist/web-http.js +29 -0
- package/dist/web-static.js +106 -0
- package/dist/web-tools.js +940 -0
- package/dist/web-types.js +2 -0
- package/dist/web.js +844 -0
- package/package.json +60 -0
- package/web/dist/assets/index-ClgkMgaq.css +2 -0
- package/web/dist/assets/index-Cu2QquuR.js +59 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/icons.svg +24 -0
- package/web/dist/index.html +20 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { relative, resolve } from "node:path";
|
|
3
|
+
import { indexLcmRecords } from "./indexer.js";
|
|
4
|
+
import { normalizeChatRecords } from "./normalize.js";
|
|
5
|
+
import { computeLcmRecordKey } from "./store.js";
|
|
6
|
+
const DEFAULT_YIELD_EVERY_N = 1024;
|
|
7
|
+
const INDEX_BATCH_SIZE = 32;
|
|
8
|
+
export async function backfillFromChatLogs(deps, options) {
|
|
9
|
+
void deps.memoryStore;
|
|
10
|
+
void deps.embeddingProvider;
|
|
11
|
+
const report = emptyReport();
|
|
12
|
+
const dataDir = resolve(options.dataDir);
|
|
13
|
+
const yieldEveryN = options.yieldEveryN ?? DEFAULT_YIELD_EVERY_N;
|
|
14
|
+
const channels = options.channels ? new Set(options.channels) : null;
|
|
15
|
+
let recordsProcessed = 0;
|
|
16
|
+
const emit = (phase, file) => {
|
|
17
|
+
options.onProgress?.({ phase, file, recordsProcessed, report: { ...report } });
|
|
18
|
+
};
|
|
19
|
+
const tick = async () => {
|
|
20
|
+
if (options.signal?.aborted)
|
|
21
|
+
return false;
|
|
22
|
+
if (yieldEveryN > 0 && recordsProcessed > 0 && recordsProcessed % yieldEveryN === 0) {
|
|
23
|
+
await new Promise((resolveYield) => setTimeout(resolveYield, 0));
|
|
24
|
+
}
|
|
25
|
+
return !options.signal?.aborted;
|
|
26
|
+
};
|
|
27
|
+
for (const chatFile of await listChatFiles(dataDir, channels, report)) {
|
|
28
|
+
if (options.signal?.aborted)
|
|
29
|
+
break;
|
|
30
|
+
emit("chat_file", chatFile.sourcePath);
|
|
31
|
+
let records;
|
|
32
|
+
try {
|
|
33
|
+
records = await readChatLogFile(chatFile.filePath);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
report.errors.push(formatError(`Failed to read chat log ${chatFile.sourcePath}`, error));
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
report.chatFilesProcessed += 1;
|
|
40
|
+
for (const group of groupByBackfillSegment(chatFile.channelKey, records)) {
|
|
41
|
+
if (options.signal?.aborted)
|
|
42
|
+
break;
|
|
43
|
+
recordsProcessed += group.records.length;
|
|
44
|
+
const batch = normalizeChatRecords(group.records, {
|
|
45
|
+
segmentId: group.segmentId,
|
|
46
|
+
sessionId: group.segmentId,
|
|
47
|
+
channelKey: chatFile.channelKey,
|
|
48
|
+
sourcePath: chatFile.sourcePath,
|
|
49
|
+
});
|
|
50
|
+
if (batch.segments.length === 0 && batch.records.length === 0) {
|
|
51
|
+
if (!(await tick()))
|
|
52
|
+
break;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (options.dryRun) {
|
|
56
|
+
report.segmentsCreated += countMissingSegments(deps.lcmStore, batch.segments.map((segment) => segment.id));
|
|
57
|
+
const existing = countExistingRecords(deps.lcmStore, batch.records);
|
|
58
|
+
report.recordsSkippedDuplicate += existing;
|
|
59
|
+
report.recordsInserted += batch.records.length - existing;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
for (const segment of batch.segments) {
|
|
63
|
+
if (!deps.lcmStore.getSegment(segment.id))
|
|
64
|
+
report.segmentsCreated += 1;
|
|
65
|
+
deps.lcmStore.ensureSegment(segment);
|
|
66
|
+
}
|
|
67
|
+
const inserted = [];
|
|
68
|
+
for (const record of batch.records) {
|
|
69
|
+
if (recordExists(deps.lcmStore, record)) {
|
|
70
|
+
report.recordsSkippedDuplicate += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const id = deps.lcmStore.insertRecord(record);
|
|
74
|
+
const stored = deps.lcmStore.getRecord(id);
|
|
75
|
+
if (!stored)
|
|
76
|
+
throw new Error(`Failed to read backfilled LCM record: ${id}`);
|
|
77
|
+
inserted.push(stored);
|
|
78
|
+
report.recordsInserted += 1;
|
|
79
|
+
if (inserted.length >= INDEX_BATCH_SIZE) {
|
|
80
|
+
report.indexedChunks += (await indexLcmRecords({ indexer: deps.indexer, records: inserted, signal: options.signal })).ids.length;
|
|
81
|
+
inserted.length = 0;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (inserted.length > 0) {
|
|
85
|
+
report.indexedChunks += (await indexLcmRecords({ indexer: deps.indexer, records: inserted, signal: options.signal })).ids.length;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!(await tick()))
|
|
89
|
+
break;
|
|
90
|
+
emit("chat_records", chatFile.sourcePath);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const transcriptPath of await listTranscriptFiles(dataDir, report)) {
|
|
94
|
+
if (options.signal?.aborted)
|
|
95
|
+
break;
|
|
96
|
+
report.transcriptFilesProcessed += 1;
|
|
97
|
+
emit("transcript_file", relative(dataDir, transcriptPath));
|
|
98
|
+
// TODO: Transcript JSONL rows do not currently have a Familiar-owned, typed
|
|
99
|
+
// schema that can be safely matched to ChatLogRecord by (jobId, timestamp).
|
|
100
|
+
// Keep transcript discovery here, but avoid force-fitting structured parts.
|
|
101
|
+
if (!(await tick()))
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
emit(options.signal?.aborted ? "aborted" : "complete");
|
|
105
|
+
return report;
|
|
106
|
+
}
|
|
107
|
+
function emptyReport() {
|
|
108
|
+
return {
|
|
109
|
+
chatFilesProcessed: 0,
|
|
110
|
+
transcriptFilesProcessed: 0,
|
|
111
|
+
recordsInserted: 0,
|
|
112
|
+
recordsSkippedDuplicate: 0,
|
|
113
|
+
segmentsCreated: 0,
|
|
114
|
+
summariesInserted: 0,
|
|
115
|
+
indexedChunks: 0,
|
|
116
|
+
errors: [],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async function listChatFiles(dataDir, channels, report) {
|
|
120
|
+
const chatDir = resolve(dataDir, "chat");
|
|
121
|
+
let channelEntries;
|
|
122
|
+
try {
|
|
123
|
+
channelEntries = await readdir(chatDir, { withFileTypes: true });
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (errorCode(error) === "ENOENT")
|
|
127
|
+
return [];
|
|
128
|
+
report.errors.push(formatError(`Failed to read chat directory ${chatDir}`, error));
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
const files = [];
|
|
132
|
+
for (const entry of channelEntries
|
|
133
|
+
.filter((item) => item.isDirectory())
|
|
134
|
+
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
135
|
+
if (channels && !channels.has(entry.name))
|
|
136
|
+
continue;
|
|
137
|
+
const channelDir = resolve(chatDir, entry.name);
|
|
138
|
+
let dateEntries;
|
|
139
|
+
try {
|
|
140
|
+
dateEntries = await readdir(channelDir, { withFileTypes: true });
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
report.errors.push(formatError(`Failed to read channel directory ${channelDir}`, error));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
for (const file of dateEntries.filter((item) => item.isFile() && /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(item.name))) {
|
|
147
|
+
const filePath = resolve(channelDir, file.name);
|
|
148
|
+
files.push({
|
|
149
|
+
channelKey: entry.name,
|
|
150
|
+
filePath,
|
|
151
|
+
sourcePath: relative(dataDir, filePath),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return files.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
|
|
156
|
+
}
|
|
157
|
+
async function listTranscriptFiles(dataDir, report) {
|
|
158
|
+
const transcriptDir = resolve(dataDir, "transcripts");
|
|
159
|
+
let entries;
|
|
160
|
+
try {
|
|
161
|
+
entries = await readdir(transcriptDir, { withFileTypes: true });
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (errorCode(error) === "ENOENT")
|
|
165
|
+
return [];
|
|
166
|
+
report.errors.push(formatError(`Failed to read transcript directory ${transcriptDir}`, error));
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
return entries
|
|
170
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
171
|
+
.map((entry) => resolve(transcriptDir, entry.name))
|
|
172
|
+
.sort();
|
|
173
|
+
}
|
|
174
|
+
async function readChatLogFile(filePath) {
|
|
175
|
+
const content = await readFile(filePath, "utf8");
|
|
176
|
+
const records = [];
|
|
177
|
+
for (const [index, line] of content.split(/\r?\n/).entries()) {
|
|
178
|
+
if (!line.trim())
|
|
179
|
+
continue;
|
|
180
|
+
const parsed = JSON.parse(line);
|
|
181
|
+
if (!isChatLogRecord(parsed))
|
|
182
|
+
throw new Error(`Malformed chat log record: ${filePath}:${index + 1}`);
|
|
183
|
+
records.push(parsed);
|
|
184
|
+
}
|
|
185
|
+
return records.sort((a, b) => a.recordId - b.recordId);
|
|
186
|
+
}
|
|
187
|
+
function isChatLogRecord(value) {
|
|
188
|
+
if (!value || typeof value !== "object")
|
|
189
|
+
return false;
|
|
190
|
+
const record = value;
|
|
191
|
+
return typeof record.recordId === "number" && typeof record.ts === "string" && typeof record.type === "string";
|
|
192
|
+
}
|
|
193
|
+
function groupByBackfillSegment(channelKey, records) {
|
|
194
|
+
const groups = [];
|
|
195
|
+
let current = [];
|
|
196
|
+
let sequence = 0;
|
|
197
|
+
const flush = () => {
|
|
198
|
+
const firstRecord = current[0];
|
|
199
|
+
if (!firstRecord)
|
|
200
|
+
return;
|
|
201
|
+
groups.push({
|
|
202
|
+
segmentId: `backfill-${channelKey}-${sanitizeSegmentIdPart(firstRecord.ts)}-${sequence}`,
|
|
203
|
+
records: current,
|
|
204
|
+
});
|
|
205
|
+
sequence += 1;
|
|
206
|
+
current = [];
|
|
207
|
+
};
|
|
208
|
+
for (const record of records) {
|
|
209
|
+
if (record.type === "control" && record.command === "new" && current.length > 0)
|
|
210
|
+
flush();
|
|
211
|
+
current.push(record);
|
|
212
|
+
}
|
|
213
|
+
flush();
|
|
214
|
+
return groups;
|
|
215
|
+
}
|
|
216
|
+
function sanitizeSegmentIdPart(value) {
|
|
217
|
+
return value.replace(/[^A-Za-z0-9._=-]+/g, "_").slice(0, 160) || "unknown";
|
|
218
|
+
}
|
|
219
|
+
function countMissingSegments(lcmStore, segmentIds) {
|
|
220
|
+
let missing = 0;
|
|
221
|
+
for (const segmentId of new Set(segmentIds)) {
|
|
222
|
+
if (!lcmStore.getSegment(segmentId))
|
|
223
|
+
missing += 1;
|
|
224
|
+
}
|
|
225
|
+
return missing;
|
|
226
|
+
}
|
|
227
|
+
function countExistingRecords(lcmStore, records) {
|
|
228
|
+
let existing = 0;
|
|
229
|
+
for (const record of records) {
|
|
230
|
+
if (recordExists(lcmStore, record))
|
|
231
|
+
existing += 1;
|
|
232
|
+
}
|
|
233
|
+
return existing;
|
|
234
|
+
}
|
|
235
|
+
function recordExists(lcmStore, record) {
|
|
236
|
+
return !!lcmStore.db
|
|
237
|
+
.prepare("SELECT 1 FROM lcm_records WHERE record_key = ? LIMIT 1")
|
|
238
|
+
.get(computeLcmRecordKey(record));
|
|
239
|
+
}
|
|
240
|
+
function errorCode(error) {
|
|
241
|
+
return error && typeof error === "object" && "code" in error
|
|
242
|
+
? String(error.code)
|
|
243
|
+
: undefined;
|
|
244
|
+
}
|
|
245
|
+
function formatError(prefix, error) {
|
|
246
|
+
return `${prefix}: ${error instanceof Error ? error.message : String(error)}`;
|
|
247
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { estimateTextTokens, selectRetainedSummaries } from "./context.js";
|
|
2
|
+
import { indexLcmSummaries } from "./indexer.js";
|
|
3
|
+
import { capSummaryText } from "./summarizer.js";
|
|
4
|
+
export async function condense(input) {
|
|
5
|
+
const groupSize = positiveInteger(input.config.condenseGroupSize, "condenseGroupSize");
|
|
6
|
+
const maxDepth = positiveInteger(input.config.maxSummaryDepth, "maxSummaryDepth");
|
|
7
|
+
if (input.depth >= maxDepth)
|
|
8
|
+
return [];
|
|
9
|
+
const children = input.store
|
|
10
|
+
.listSummaries(input.segmentId)
|
|
11
|
+
.filter((summary) => summary.depth === input.depth &&
|
|
12
|
+
summary.status === "ready" &&
|
|
13
|
+
(input.candidateIds === undefined || input.candidateIds.includes(summary.id)) &&
|
|
14
|
+
input.store.getSummaryChildren(summary.id).length === 0)
|
|
15
|
+
.sort(compareCoverage);
|
|
16
|
+
const created = [];
|
|
17
|
+
for (let index = 0; index + groupSize <= children.length; index += groupSize) {
|
|
18
|
+
const group = children.slice(index, index + groupSize);
|
|
19
|
+
const coversFromRecordId = minNullable(group.map((summary) => summary.coversFromRecordId));
|
|
20
|
+
const coversToRecordId = maxNullable(group.map((summary) => summary.coversToRecordId));
|
|
21
|
+
const text = capSummaryText(await summarizeCondensedGroup({
|
|
22
|
+
group,
|
|
23
|
+
targetTokens: input.config.leafTargetTokens,
|
|
24
|
+
depth: input.depth + 1,
|
|
25
|
+
summarizer: input.summarizer,
|
|
26
|
+
signal: input.signal,
|
|
27
|
+
}), input.config.leafTargetTokens);
|
|
28
|
+
const id = input.store.insertSummary({
|
|
29
|
+
segmentId: input.segmentId,
|
|
30
|
+
depth: input.depth + 1,
|
|
31
|
+
status: "ready",
|
|
32
|
+
text,
|
|
33
|
+
coversFromRecordId,
|
|
34
|
+
coversToRecordId,
|
|
35
|
+
source: {
|
|
36
|
+
sourceType: "manual",
|
|
37
|
+
sourceRef: `lcm_condense:d${input.depth + 1}:${group.map((summary) => summary.id).join("-")}`,
|
|
38
|
+
},
|
|
39
|
+
sourceItems: group.map((summary) => ({ summaryId: summary.id, sourceRef: `lcm_summary:${summary.id}` })),
|
|
40
|
+
parents: group.map((summary) => summary.id),
|
|
41
|
+
metadata: {
|
|
42
|
+
source: "condense",
|
|
43
|
+
childDepth: input.depth,
|
|
44
|
+
childSummaryIds: group.map((summary) => summary.id),
|
|
45
|
+
...coverageMetadataFromSummaries(group),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const summary = input.store.getSummary(id);
|
|
49
|
+
if (summary) {
|
|
50
|
+
created.push(summary);
|
|
51
|
+
if (input.indexer) {
|
|
52
|
+
await indexLcmSummaries({ indexer: input.indexer, summaries: [summary], signal: input.signal }).catch((error) => console.error("memory LCM condensed summary indexing failed", error));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (created.length > 0 && input.depth + 1 < maxDepth) {
|
|
57
|
+
created.push(...(await condense({
|
|
58
|
+
...input,
|
|
59
|
+
depth: input.depth + 1,
|
|
60
|
+
candidateIds: created.map((summary) => summary.id),
|
|
61
|
+
})));
|
|
62
|
+
}
|
|
63
|
+
return created;
|
|
64
|
+
}
|
|
65
|
+
export function renderCondensedSummariesForContext(summaries) {
|
|
66
|
+
return selectRetainedSummaries(summaries);
|
|
67
|
+
}
|
|
68
|
+
function compareCoverage(a, b) {
|
|
69
|
+
return ((a.coversFromRecordId ?? Number.MAX_SAFE_INTEGER) - (b.coversFromRecordId ?? Number.MAX_SAFE_INTEGER) ||
|
|
70
|
+
(a.coversToRecordId ?? Number.MAX_SAFE_INTEGER) - (b.coversToRecordId ?? Number.MAX_SAFE_INTEGER) ||
|
|
71
|
+
a.id - b.id);
|
|
72
|
+
}
|
|
73
|
+
async function summarizeCondensedGroup(input) {
|
|
74
|
+
const text = renderCondensedSummaryInput(input.group);
|
|
75
|
+
if (input.summarizer.summarizeCondensed) {
|
|
76
|
+
return input.summarizer.summarizeCondensed({
|
|
77
|
+
text,
|
|
78
|
+
targetTokens: input.targetTokens,
|
|
79
|
+
depth: input.depth,
|
|
80
|
+
childSummaryCount: input.group.length,
|
|
81
|
+
}, input.signal);
|
|
82
|
+
}
|
|
83
|
+
return input.summarizer.summarizeLeaf({
|
|
84
|
+
text,
|
|
85
|
+
targetTokens: input.targetTokens,
|
|
86
|
+
mode: "normal",
|
|
87
|
+
}, input.signal);
|
|
88
|
+
}
|
|
89
|
+
function renderCondensedSummaryInput(group) {
|
|
90
|
+
return group
|
|
91
|
+
.map((summary) => [
|
|
92
|
+
`<summary id="${summary.id}" depth="${summary.depth}" from="${summary.coversFromRecordId ?? ""}" to="${summary.coversToRecordId ?? ""}" tokens="${estimateTextTokens(summary.text)}">`,
|
|
93
|
+
formatSummaryTimeRange(summary),
|
|
94
|
+
summary.text,
|
|
95
|
+
"</summary>",
|
|
96
|
+
]
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.join("\n"))
|
|
99
|
+
.join("\n\n");
|
|
100
|
+
}
|
|
101
|
+
function formatSummaryTimeRange(summary) {
|
|
102
|
+
const from = metadataString(summary.metadata?.coverageFromHappenedAt ?? summary.metadata?.timestamp);
|
|
103
|
+
const to = metadataString(summary.metadata?.coverageToHappenedAt ?? summary.metadata?.timestamp);
|
|
104
|
+
if (!from && !to)
|
|
105
|
+
return "";
|
|
106
|
+
return `[time_range ${from ?? "unknown"} - ${to ?? "unknown"}]`;
|
|
107
|
+
}
|
|
108
|
+
function metadataString(value) {
|
|
109
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
110
|
+
}
|
|
111
|
+
function minNullable(values) {
|
|
112
|
+
const present = values.filter((value) => value !== null);
|
|
113
|
+
return present.length === 0 ? null : Math.min(...present);
|
|
114
|
+
}
|
|
115
|
+
function maxNullable(values) {
|
|
116
|
+
const present = values.filter((value) => value !== null);
|
|
117
|
+
return present.length === 0 ? null : Math.max(...present);
|
|
118
|
+
}
|
|
119
|
+
function coverageMetadataFromSummaries(group) {
|
|
120
|
+
const from = firstValidTime(group.map((summary) => summary.metadata?.coverageFromHappenedAt ?? summary.metadata?.timestamp));
|
|
121
|
+
const to = lastValidTime(group.map((summary) => summary.metadata?.coverageToHappenedAt ?? summary.metadata?.timestamp));
|
|
122
|
+
return {
|
|
123
|
+
...(from ? { coverageFromHappenedAt: from } : {}),
|
|
124
|
+
...(to ? { coverageToHappenedAt: to, timestamp: to } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function firstValidTime(values) {
|
|
128
|
+
for (const value of values) {
|
|
129
|
+
if (typeof value === "string" && Number.isFinite(Date.parse(value)))
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
function lastValidTime(values) {
|
|
135
|
+
for (let index = values.length - 1; index >= 0; index -= 1) {
|
|
136
|
+
const value = values[index];
|
|
137
|
+
if (typeof value === "string" && Number.isFinite(Date.parse(value)))
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
function positiveInteger(value, name) {
|
|
143
|
+
if (!Number.isInteger(value) || value < 1)
|
|
144
|
+
throw new Error(`${name} must be an integer >= 1`);
|
|
145
|
+
return value;
|
|
146
|
+
}
|