@clawmem-ai/clawmem 0.1.15 → 0.1.17
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/README.md +6 -4
- package/openclaw.plugin.json +11 -11
- package/package.json +12 -2
- package/skills/clawmem/SKILL.md +5 -3
- package/skills/clawmem/references/collaboration.md +43 -1
- package/skills/clawmem/references/schema.md +2 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +1 -1
- package/src/conversation.test.ts +63 -13
- package/src/conversation.ts +100 -188
- package/src/github-client.test.ts +101 -0
- package/src/github-client.ts +59 -0
- package/src/memory.test.ts +154 -39
- package/src/memory.ts +139 -246
- package/src/runtime-env.ts +12 -0
- package/src/service.test.ts +118 -0
- package/src/service.ts +765 -200
- package/src/state.test.ts +119 -0
- package/src/state.ts +124 -25
- package/src/types.ts +33 -6
- package/src/utils.ts +19 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { loadState } from "./state.js";
|
|
5
|
+
|
|
6
|
+
function assert(condition: unknown, message: string): void {
|
|
7
|
+
if (!condition) throw new Error(message);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function withTempStateFile(payload: unknown, fn: (filePath: string) => Promise<void>): Promise<void> {
|
|
11
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawmem-state-"));
|
|
12
|
+
const filePath = path.join(dir, "state.json");
|
|
13
|
+
try {
|
|
14
|
+
await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
|
|
15
|
+
await fn(filePath);
|
|
16
|
+
} finally {
|
|
17
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function testMigratesLegacyV2State(): Promise<void> {
|
|
22
|
+
await withTempStateFile({
|
|
23
|
+
version: 2,
|
|
24
|
+
sessions: {
|
|
25
|
+
"main:s-1": {
|
|
26
|
+
sessionId: "s-1",
|
|
27
|
+
agentId: "main",
|
|
28
|
+
issueNumber: 10,
|
|
29
|
+
lastMirroredCount: 6,
|
|
30
|
+
turnCount: 6,
|
|
31
|
+
lastMemorySyncCount: 4,
|
|
32
|
+
summaryStatus: "pending",
|
|
33
|
+
finalizedAt: "2026-04-03T10:00:00.000Z",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
}, async (filePath) => {
|
|
37
|
+
const state = await loadState(filePath);
|
|
38
|
+
const session = state.sessions["main:s-1"];
|
|
39
|
+
assert(state.version === 4, "expected state version 4 after migration");
|
|
40
|
+
assert(Boolean(session), "expected migrated session to exist");
|
|
41
|
+
assert(session?.derived?.summary.status === "error", "expected finalized legacy sessions without a final summary to surface as needing manual attention");
|
|
42
|
+
assert(session?.derived?.memory.capturedCursor === 4, "expected legacy memory sync cursor to migrate into capturedCursor");
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function testNormalizesRunningTaskStates(): Promise<void> {
|
|
47
|
+
await withTempStateFile({
|
|
48
|
+
version: 3,
|
|
49
|
+
sessions: {
|
|
50
|
+
"main:s-2": {
|
|
51
|
+
sessionId: "s-2",
|
|
52
|
+
agentId: "main",
|
|
53
|
+
lastMirroredCount: 3,
|
|
54
|
+
turnCount: 3,
|
|
55
|
+
derived: {
|
|
56
|
+
summary: { basedOnCursor: 0, status: "running" },
|
|
57
|
+
memory: {
|
|
58
|
+
extractCursor: 1,
|
|
59
|
+
appliedCursor: 0,
|
|
60
|
+
extractStatus: "running",
|
|
61
|
+
reconcileStatus: "running",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}, async (filePath) => {
|
|
67
|
+
const state = await loadState(filePath);
|
|
68
|
+
const session = state.sessions["main:s-2"];
|
|
69
|
+
assert(session?.derived?.summary.status === "idle", "expected running summary tasks to normalize to idle on load");
|
|
70
|
+
assert(session?.derived?.memory.status === "idle", "expected running memory tasks to normalize to idle on load");
|
|
71
|
+
assert(session?.derived?.memory.capturedCursor === 0, "expected captured cursor to preserve the applied progress");
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function testPreservesCachedFinalArtifacts(): Promise<void> {
|
|
76
|
+
await withTempStateFile({
|
|
77
|
+
version: 4,
|
|
78
|
+
sessions: {
|
|
79
|
+
"main:s-3": {
|
|
80
|
+
sessionId: "s-3",
|
|
81
|
+
agentId: "main",
|
|
82
|
+
lastMirroredCount: 5,
|
|
83
|
+
turnCount: 5,
|
|
84
|
+
derived: {
|
|
85
|
+
summary: {
|
|
86
|
+
basedOnCursor: 5,
|
|
87
|
+
status: "idle",
|
|
88
|
+
text: "Recovered summary",
|
|
89
|
+
title: "Recovered title",
|
|
90
|
+
},
|
|
91
|
+
memory: {
|
|
92
|
+
capturedCursor: 0,
|
|
93
|
+
status: "error",
|
|
94
|
+
candidates: [
|
|
95
|
+
{
|
|
96
|
+
candidateId: "cand-1",
|
|
97
|
+
detail: "Store this durable fact.",
|
|
98
|
+
kind: "lesson",
|
|
99
|
+
topics: ["redis"],
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
}, async (filePath) => {
|
|
107
|
+
const state = await loadState(filePath);
|
|
108
|
+
const session = state.sessions["main:s-3"];
|
|
109
|
+
assert(session?.derived?.summary.title === "Recovered title", "expected cached finalize title to survive state load");
|
|
110
|
+
assert(session?.derived?.memory.candidates?.length === 1, "expected cached memory candidates to survive state load");
|
|
111
|
+
assert(session?.derived?.memory.candidates?.[0]?.detail === "Store this durable fact.", "expected cached candidate detail to survive state load");
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await testMigratesLegacyV2State();
|
|
116
|
+
await testNormalizesRunningTaskStates();
|
|
117
|
+
await testPreservesCachedFinalArtifacts();
|
|
118
|
+
|
|
119
|
+
console.log("state tests passed");
|
package/src/state.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import type { PluginState } from "./types.js";
|
|
3
|
+
import type { MemoryCandidate, PluginState, SessionDerivedState, SessionMirrorState, SessionTaskStatus } from "./types.js";
|
|
4
4
|
import { normalizeAgentId, sessionScopeKey } from "./utils.js";
|
|
5
5
|
|
|
6
6
|
const EMPTY_STATE: PluginState = {
|
|
7
|
-
version:
|
|
7
|
+
version: 4,
|
|
8
8
|
sessions: {},
|
|
9
9
|
};
|
|
10
10
|
|
|
@@ -38,10 +38,9 @@ function sanitizeState(value: unknown): PluginState {
|
|
|
38
38
|
return structuredClone(EMPTY_STATE);
|
|
39
39
|
}
|
|
40
40
|
const raw = value as Record<string, unknown>;
|
|
41
|
-
const sessions =
|
|
42
|
-
raw.sessions
|
|
43
|
-
|
|
44
|
-
: {};
|
|
41
|
+
const sessions = raw.sessions && typeof raw.sessions === "object"
|
|
42
|
+
? (raw.sessions as Record<string, unknown>)
|
|
43
|
+
: {};
|
|
45
44
|
const migrations: Record<string, string> = {};
|
|
46
45
|
if (raw.migrations && typeof raw.migrations === "object") {
|
|
47
46
|
for (const [k, v] of Object.entries(raw.migrations as Record<string, unknown>)) {
|
|
@@ -50,20 +49,19 @@ function sanitizeState(value: unknown): PluginState {
|
|
|
50
49
|
}
|
|
51
50
|
}
|
|
52
51
|
const out: PluginState = {
|
|
53
|
-
version:
|
|
52
|
+
version: 4,
|
|
54
53
|
sessions: {},
|
|
55
54
|
...(Object.keys(migrations).length > 0 ? { migrations } : {}),
|
|
56
55
|
};
|
|
57
56
|
for (const [storedKey, sessionValue] of Object.entries(sessions)) {
|
|
58
|
-
if (!sessionValue || typeof sessionValue !== "object" || !storedKey.trim())
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
57
|
+
if (!sessionValue || typeof sessionValue !== "object" || !storedKey.trim()) continue;
|
|
61
58
|
const rawSession = sessionValue as Record<string, unknown>;
|
|
62
59
|
const sessionId = readString(rawSession.sessionId) ?? storedKey.trim();
|
|
63
|
-
if (!sessionId)
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
60
|
+
if (!sessionId) continue;
|
|
66
61
|
const agentId = normalizeAgentId(readString(rawSession.agentId));
|
|
62
|
+
const lastMirroredCount = readNumber(rawSession.lastMirroredCount) ?? 0;
|
|
63
|
+
const finalizedAt = readString(rawSession.finalizedAt);
|
|
64
|
+
const derived = sanitizeDerivedState(rawSession, lastMirroredCount, finalizedAt);
|
|
67
65
|
out.sessions[sessionScopeKey(sessionId, agentId)] = {
|
|
68
66
|
sessionId,
|
|
69
67
|
sessionKey: readString(rawSession.sessionKey),
|
|
@@ -71,14 +69,12 @@ function sanitizeState(value: unknown): PluginState {
|
|
|
71
69
|
agentId,
|
|
72
70
|
issueNumber: readNumber(rawSession.issueNumber),
|
|
73
71
|
issueTitle: readString(rawSession.issueTitle),
|
|
74
|
-
titleSource:
|
|
75
|
-
lastMirroredCount
|
|
72
|
+
titleSource: readTitleSource(rawSession.titleSource),
|
|
73
|
+
lastMirroredCount,
|
|
76
74
|
turnCount: readNumber(rawSession.turnCount) ?? 0,
|
|
77
|
-
|
|
78
|
-
summaryStatus: readEnum(rawSession.summaryStatus, ["pending", "complete"]),
|
|
79
|
-
finalizedAt: readString(rawSession.finalizedAt),
|
|
75
|
+
finalizedAt,
|
|
80
76
|
lastSummaryHash: readString(rawSession.lastSummaryHash),
|
|
81
|
-
|
|
77
|
+
derived,
|
|
82
78
|
createdAt: readString(rawSession.createdAt),
|
|
83
79
|
updatedAt: readString(rawSession.updatedAt),
|
|
84
80
|
};
|
|
@@ -86,10 +82,63 @@ function sanitizeState(value: unknown): PluginState {
|
|
|
86
82
|
return out;
|
|
87
83
|
}
|
|
88
84
|
|
|
85
|
+
function sanitizeDerivedState(
|
|
86
|
+
rawSession: Record<string, unknown>,
|
|
87
|
+
lastMirroredCount: number,
|
|
88
|
+
finalizedAt?: string,
|
|
89
|
+
): SessionDerivedState {
|
|
90
|
+
const rawDerived = asRecord(rawSession.derived);
|
|
91
|
+
const rawSummary = asRecord(rawDerived?.summary);
|
|
92
|
+
const rawMemory = asRecord(rawDerived?.memory);
|
|
93
|
+
const legacySummaryStatus = readEnum(rawSession.summaryStatus, ["pending", "complete"]);
|
|
94
|
+
const legacyMemoryCursor = readNumber(rawSession.lastMemorySyncCount);
|
|
95
|
+
|
|
96
|
+
const summaryText = readString(rawSummary?.text);
|
|
97
|
+
const summaryTitle = readString(rawSummary?.title);
|
|
98
|
+
const summaryStatus = readTaskStatus(
|
|
99
|
+
rawSummary?.status,
|
|
100
|
+
summaryText || legacySummaryStatus === "complete" ? "complete" : "idle",
|
|
101
|
+
);
|
|
102
|
+
const summaryCursor = clampCursor(
|
|
103
|
+
readNumber(rawSummary?.basedOnCursor),
|
|
104
|
+
summaryStatus === "complete" ? lastMirroredCount : 0,
|
|
105
|
+
lastMirroredCount,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const capturedCursor = clampCursor(
|
|
109
|
+
readNumber(rawMemory?.capturedCursor)
|
|
110
|
+
?? readNumber(rawMemory?.appliedCursor)
|
|
111
|
+
?? legacyMemoryCursor,
|
|
112
|
+
summaryStatus === "complete" ? lastMirroredCount : 0,
|
|
113
|
+
lastMirroredCount,
|
|
114
|
+
);
|
|
115
|
+
const memoryStatus = readTaskStatus(
|
|
116
|
+
rawMemory?.status ?? rawMemory?.extractStatus ?? rawMemory?.reconcileStatus,
|
|
117
|
+
capturedCursor >= lastMirroredCount && lastMirroredCount > 0 ? "complete" : "idle",
|
|
118
|
+
);
|
|
119
|
+
const candidates = readMemoryCandidates(rawMemory?.candidates);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
summary: {
|
|
123
|
+
basedOnCursor: summaryCursor,
|
|
124
|
+
status: finalizedAt && summaryStatus === "idle" && lastMirroredCount > 0 ? "error" : summaryStatus,
|
|
125
|
+
...(summaryText ? { text: summaryText } : {}),
|
|
126
|
+
...(summaryTitle ? { title: summaryTitle } : {}),
|
|
127
|
+
...(readString(rawSummary?.lastError) ? { lastError: readString(rawSummary?.lastError) } : {}),
|
|
128
|
+
...(readString(rawSummary?.updatedAt) ? { updatedAt: readString(rawSummary?.updatedAt) } : {}),
|
|
129
|
+
},
|
|
130
|
+
memory: {
|
|
131
|
+
capturedCursor,
|
|
132
|
+
status: memoryStatus,
|
|
133
|
+
...(candidates ? { candidates } : {}),
|
|
134
|
+
...(readString(rawMemory?.lastError) ? { lastError: readString(rawMemory?.lastError) } : {}),
|
|
135
|
+
...(readString(rawMemory?.updatedAt) ? { updatedAt: readString(rawMemory?.updatedAt) } : {}),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
89
140
|
function readString(value: unknown): string | undefined {
|
|
90
|
-
if (typeof value !== "string")
|
|
91
|
-
return undefined;
|
|
92
|
-
}
|
|
141
|
+
if (typeof value !== "string") return undefined;
|
|
93
142
|
const trimmed = value.trim();
|
|
94
143
|
return trimmed ? trimmed : undefined;
|
|
95
144
|
}
|
|
@@ -100,8 +149,58 @@ function readEnum<T extends string>(value: unknown, allowed: T[]): T | undefined
|
|
|
100
149
|
}
|
|
101
150
|
|
|
102
151
|
function readNumber(value: unknown): number | undefined {
|
|
103
|
-
if (typeof value !== "number" || !Number.isFinite(value))
|
|
104
|
-
return undefined;
|
|
105
|
-
}
|
|
152
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
106
153
|
return Math.max(0, Math.floor(value));
|
|
107
154
|
}
|
|
155
|
+
|
|
156
|
+
function readTaskStatus(value: unknown, fallback: SessionTaskStatus): SessionTaskStatus {
|
|
157
|
+
const status = readEnum(value, ["idle", "pending", "running", "complete", "error"]);
|
|
158
|
+
if (!status) return fallback;
|
|
159
|
+
if (status === "pending" || status === "running") return "idle";
|
|
160
|
+
return status;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function readTitleSource(value: unknown): "placeholder" | "llm" | undefined {
|
|
164
|
+
const source = readEnum(value, ["placeholder", "digest", "llm"]);
|
|
165
|
+
if (!source) return undefined;
|
|
166
|
+
return source === "digest" ? "llm" : source;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function readMemoryCandidates(value: unknown): MemoryCandidate[] | undefined {
|
|
170
|
+
if (!Array.isArray(value)) return undefined;
|
|
171
|
+
const out = value
|
|
172
|
+
.map((entry) => sanitizeMemoryCandidate(entry))
|
|
173
|
+
.filter((candidate): candidate is MemoryCandidate => candidate !== null);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function sanitizeMemoryCandidate(value: unknown): MemoryCandidate | null {
|
|
178
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
179
|
+
const record = value as Record<string, unknown>;
|
|
180
|
+
const candidateId = readString(record.candidateId);
|
|
181
|
+
const detail = readString(record.detail);
|
|
182
|
+
if (!candidateId || !detail) return null;
|
|
183
|
+
const title = readString(record.title);
|
|
184
|
+
const kind = readString(record.kind);
|
|
185
|
+
const topics = Array.isArray(record.topics)
|
|
186
|
+
? record.topics.map((topic) => readString(topic)).filter((topic): topic is string => Boolean(topic))
|
|
187
|
+
: undefined;
|
|
188
|
+
const evidence = readString(record.evidence);
|
|
189
|
+
return {
|
|
190
|
+
candidateId,
|
|
191
|
+
detail,
|
|
192
|
+
...(title ? { title } : {}),
|
|
193
|
+
...(kind ? { kind } : {}),
|
|
194
|
+
...(topics && topics.length > 0 ? { topics } : {}),
|
|
195
|
+
...(evidence ? { evidence } : {}),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function clampCursor(value: number | undefined, fallback: number, max: number): number {
|
|
200
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return Math.min(max, Math.max(0, fallback));
|
|
201
|
+
return Math.min(max, Math.max(0, Math.floor(value)));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
205
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
206
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -16,8 +16,8 @@ export type ClawMemPluginConfig = {
|
|
|
16
16
|
agents: Record<string, ClawMemAgentConfig>;
|
|
17
17
|
memoryRecallLimit: number;
|
|
18
18
|
memoryAutoRecallLimit: number;
|
|
19
|
-
turnCommentDelayMs: number;
|
|
20
19
|
summaryWaitTimeoutMs: number;
|
|
20
|
+
memoryExtractWaitTimeoutMs: number;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export type ClawMemResolvedRoute = {
|
|
@@ -32,16 +32,43 @@ export type ClawMemResolvedRoute = {
|
|
|
32
32
|
export type BootstrapIdentityResponse = { token: string; repo_full_name: string };
|
|
33
33
|
export type AgentRegistrationResponse = BootstrapIdentityResponse & { login: string };
|
|
34
34
|
export type AnonymousSessionResponse = BootstrapIdentityResponse & { owner_login: string; repo_name: string };
|
|
35
|
+
export type SessionTaskStatus = "idle" | "complete" | "error";
|
|
36
|
+
export type MemoryCandidate = {
|
|
37
|
+
candidateId: string;
|
|
38
|
+
detail: string;
|
|
39
|
+
title?: string;
|
|
40
|
+
kind?: string;
|
|
41
|
+
topics?: string[];
|
|
42
|
+
evidence?: string;
|
|
43
|
+
};
|
|
44
|
+
export type SessionSummaryState = {
|
|
45
|
+
basedOnCursor: number;
|
|
46
|
+
status: SessionTaskStatus;
|
|
47
|
+
text?: string;
|
|
48
|
+
title?: string;
|
|
49
|
+
lastError?: string;
|
|
50
|
+
updatedAt?: string;
|
|
51
|
+
};
|
|
52
|
+
export type SessionMemoryState = {
|
|
53
|
+
capturedCursor: number;
|
|
54
|
+
status: SessionTaskStatus;
|
|
55
|
+
candidates?: MemoryCandidate[];
|
|
56
|
+
lastError?: string;
|
|
57
|
+
updatedAt?: string;
|
|
58
|
+
};
|
|
59
|
+
export type SessionDerivedState = {
|
|
60
|
+
summary: SessionSummaryState;
|
|
61
|
+
memory: SessionMemoryState;
|
|
62
|
+
};
|
|
35
63
|
export type SessionMirrorState = {
|
|
36
64
|
sessionId: string; sessionKey?: string; sessionFile?: string; agentId?: string;
|
|
37
65
|
issueNumber?: number; issueTitle?: string; titleSource?: "placeholder" | "llm";
|
|
38
|
-
lastMirroredCount: number; turnCount: number;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
finalizedAt?: string; lastSummaryHash?: string; lastTurnHash?: string;
|
|
66
|
+
lastMirroredCount: number; turnCount: number;
|
|
67
|
+
finalizedAt?: string; lastSummaryHash?: string;
|
|
68
|
+
derived?: SessionDerivedState;
|
|
42
69
|
createdAt?: string; updatedAt?: string;
|
|
43
70
|
};
|
|
44
|
-
export type PluginState = { version:
|
|
71
|
+
export type PluginState = { version: 4; sessions: Record<string, SessionMirrorState>; migrations?: Record<string, string> };
|
|
45
72
|
export type NormalizedMessage = { role: string; text: string; toolName?: string; timestamp?: string; stopReason?: string };
|
|
46
73
|
export type TranscriptSnapshot = { sessionId?: string; messages: NormalizedMessage[] };
|
|
47
74
|
export type MemoryDraft = { title?: string; detail: string; kind?: string; topics?: string[] };
|
package/src/utils.ts
CHANGED
|
@@ -46,6 +46,25 @@ export function fmtTranscript(msgs: NormalizedMessage[]): string {
|
|
|
46
46
|
return msgs.map((m, i) => `${i + 1}. ${m.role === "assistant" ? "assistant" : "user"}: ${m.text}`).join("\n\n");
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export function fmtTranscriptFrom(msgs: NormalizedMessage[], startIndex: number): string {
|
|
50
|
+
return msgs.map((m, i) => `${startIndex + i + 1}. ${m.role === "assistant" ? "assistant" : "user"}: ${m.text}`).join("\n\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function sliceTranscriptDelta(
|
|
54
|
+
msgs: NormalizedMessage[],
|
|
55
|
+
fromIndex: number,
|
|
56
|
+
anchorCount = 2,
|
|
57
|
+
): { anchorStart: number; deltaStart: number; anchorMessages: NormalizedMessage[]; deltaMessages: NormalizedMessage[] } {
|
|
58
|
+
const deltaStart = Math.min(Math.max(0, Math.floor(fromIndex)), msgs.length);
|
|
59
|
+
const anchorStart = Math.max(0, deltaStart - Math.max(0, Math.floor(anchorCount)));
|
|
60
|
+
return {
|
|
61
|
+
anchorStart,
|
|
62
|
+
deltaStart,
|
|
63
|
+
anchorMessages: msgs.slice(anchorStart, deltaStart),
|
|
64
|
+
deltaMessages: msgs.slice(deltaStart),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
export function localDate(d: Date = new Date()): string {
|
|
50
69
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
51
70
|
}
|