@context-vault/core 2.17.0 → 2.17.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-vault/core",
3
- "version": "2.17.0",
3
+ "version": "2.17.1",
4
4
  "type": "module",
5
5
  "description": "Shared core: capture, index, retrieve, tools, and utilities for context-vault",
6
6
  "main": "src/index.js",
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  "license": "MIT",
25
25
  "engines": {
26
- "node": ">=24"
26
+ "node": ">=20"
27
27
  },
28
28
  "author": "Felix Hellstrom",
29
29
  "repository": {
@@ -36,7 +36,6 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@anthropic-ai/sdk": "^0.78.0",
40
39
  "@modelcontextprotocol/sdk": "^1.26.0",
41
40
  "sqlite-vec": "^0.1.0"
42
41
  },
@@ -38,6 +38,7 @@ export function writeEntryFile(
38
38
  identity_key,
39
39
  expires_at,
40
40
  supersedes,
41
+ related_to,
41
42
  },
42
43
  ) {
43
44
  // P5: folder is now a top-level param; also accept from meta for backward compat
@@ -64,6 +65,7 @@ export function writeEntryFile(
64
65
  if (identity_key) fmFields.identity_key = identity_key;
65
66
  if (expires_at) fmFields.expires_at = expires_at;
66
67
  if (supersedes?.length) fmFields.supersedes = supersedes;
68
+ if (related_to?.length) fmFields.related_to = related_to;
67
69
  fmFields.tags = tags || [];
68
70
  fmFields.source = source || "claude-code";
69
71
  fmFields.created = created;
@@ -27,6 +27,7 @@ export function writeEntry(
27
27
  identity_key,
28
28
  expires_at,
29
29
  supersedes,
30
+ related_to,
30
31
  source_files,
31
32
  tier,
32
33
  userId,
@@ -88,6 +89,7 @@ export function writeEntry(
88
89
  identity_key,
89
90
  expires_at,
90
91
  supersedes,
92
+ related_to,
91
93
  });
92
94
 
93
95
  return {
@@ -105,6 +107,7 @@ export function writeEntry(
105
107
  identity_key,
106
108
  expires_at,
107
109
  supersedes,
110
+ related_to: related_to || null,
108
111
  source_files: source_files || null,
109
112
  tier: tier || null,
110
113
  userId: userId || null,
@@ -126,6 +129,9 @@ export function updateEntryFile(ctx, existing, updates) {
126
129
 
127
130
  const existingMeta = existing.meta ? JSON.parse(existing.meta) : {};
128
131
  const existingTags = existing.tags ? JSON.parse(existing.tags) : [];
132
+ const existingRelatedTo = existing.related_to
133
+ ? JSON.parse(existing.related_to)
134
+ : fmMeta.related_to || null;
129
135
 
130
136
  const title = updates.title !== undefined ? updates.title : existing.title;
131
137
  const body = updates.body !== undefined ? updates.body : existing.body;
@@ -138,6 +144,8 @@ export function updateEntryFile(ctx, existing, updates) {
138
144
  updates.supersedes !== undefined
139
145
  ? updates.supersedes
140
146
  : fmMeta.supersedes || null;
147
+ const related_to =
148
+ updates.related_to !== undefined ? updates.related_to : existingRelatedTo;
141
149
  const source_files =
142
150
  updates.source_files !== undefined
143
151
  ? updates.source_files
@@ -162,6 +170,7 @@ export function updateEntryFile(ctx, existing, updates) {
162
170
  if (existing.identity_key) fmFields.identity_key = existing.identity_key;
163
171
  if (expires_at) fmFields.expires_at = expires_at;
164
172
  if (supersedes?.length) fmFields.supersedes = supersedes;
173
+ if (related_to?.length) fmFields.related_to = related_to;
165
174
  fmFields.tags = tags;
166
175
  fmFields.source = source || "claude-code";
167
176
  fmFields.created = fmMeta.created || existing.created_at;
@@ -189,6 +198,7 @@ export function updateEntryFile(ctx, existing, updates) {
189
198
  identity_key: existing.identity_key,
190
199
  expires_at,
191
200
  supersedes,
201
+ related_to: related_to || null,
192
202
  source_files: source_files || null,
193
203
  userId: existing.user_id || null,
194
204
  };
@@ -217,6 +227,10 @@ export async function captureAndIndex(ctx, data) {
217
227
  }
218
228
  }
219
229
  }
230
+ // Store related_to links in DB
231
+ if (entry.related_to?.length && ctx.stmts.updateRelatedTo) {
232
+ ctx.stmts.updateRelatedTo.run(JSON.stringify(entry.related_to), entry.id);
233
+ }
220
234
  return entry;
221
235
  } catch (err) {
222
236
  // Rollback: restore previous content for entity upserts, delete for new entries
@@ -23,6 +23,7 @@ const KIND_CATEGORY = {
23
23
  source: "entity",
24
24
  bucket: "entity",
25
25
  // Event — append-only, decaying
26
+ event: "event",
26
27
  conversation: "event",
27
28
  message: "event",
28
29
  session: "event",
package/src/core/files.js CHANGED
@@ -37,42 +37,19 @@ export function slugify(text, maxLen = 60) {
37
37
  return slug;
38
38
  }
39
39
 
40
- const PLURAL_MAP = {
41
- insight: "insights",
42
- decision: "decisions",
43
- pattern: "patterns",
44
- status: "statuses",
45
- analysis: "analyses",
46
- contact: "contacts",
47
- project: "projects",
48
- tool: "tools",
49
- source: "sources",
50
- conversation: "conversations",
51
- message: "messages",
52
- session: "sessions",
53
- log: "logs",
54
- feedback: "feedbacks",
55
- };
56
-
57
- const SINGULAR_MAP = Object.fromEntries(
58
- Object.entries(PLURAL_MAP).map(([k, v]) => [v, k]),
59
- );
60
-
40
+ /** Map kind name to its directory name. Kind names are used as-is (no pluralization). */
61
41
  export function kindToDir(kind) {
62
- if (PLURAL_MAP[kind]) return PLURAL_MAP[kind];
63
- return kind.endsWith("s") ? kind : kind + "s";
42
+ return kind;
64
43
  }
65
44
 
45
+ /** Map directory name back to kind name. Directory names equal kind names (identity). */
66
46
  export function dirToKind(dirName) {
67
- if (SINGULAR_MAP[dirName]) return SINGULAR_MAP[dirName];
68
- return dirName.replace(/s$/, "");
47
+ return dirName;
69
48
  }
70
49
 
71
- /** Normalize a kind input (singular or plural) to its canonical singular form. */
50
+ /** Normalize a kind input to its canonical form. Kind names are returned as-is. */
72
51
  export function normalizeKind(input) {
73
- if (PLURAL_MAP[input]) return input; // Already a known singular kind
74
- if (SINGULAR_MAP[input]) return SINGULAR_MAP[input]; // Known plural → singular
75
- return input; // Unknown — use as-is (don't strip 's')
52
+ return input;
76
53
  }
77
54
 
78
55
  /** Returns relative path from vault root → kind dir: "knowledge/insights", "events/sessions", etc. */
@@ -69,6 +69,7 @@ const RESERVED_FM_KEYS = new Set([
69
69
  "identity_key",
70
70
  "expires_at",
71
71
  "supersedes",
72
+ "related_to",
72
73
  ]);
73
74
 
74
75
  export function extractCustomMeta(fmMeta) {
@@ -0,0 +1,161 @@
1
+ /**
2
+ * linking.js — Pure graph traversal for related_to links.
3
+ *
4
+ * All functions accept a db handle and return data — no side effects.
5
+ * The calling layer (get-context handler) is responsible for I/O wiring.
6
+ */
7
+
8
+ /**
9
+ * Parse a `related_to` JSON string from the DB into an array of ID strings.
10
+ * Returns an empty array on any parse failure or null input.
11
+ *
12
+ * @param {string|null|undefined} raw
13
+ * @returns {string[]}
14
+ */
15
+ export function parseRelatedTo(raw) {
16
+ if (!raw) return [];
17
+ try {
18
+ const parsed = JSON.parse(raw);
19
+ if (!Array.isArray(parsed)) return [];
20
+ return parsed.filter((id) => typeof id === "string" && id.trim());
21
+ } catch {
22
+ return [];
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Fetch vault entries by their IDs, scoped to a user.
28
+ * Returns only entries that exist and are not expired or superseded.
29
+ *
30
+ * @param {import('node:sqlite').DatabaseSync} db
31
+ * @param {string[]} ids
32
+ * @param {string|null|undefined} userId
33
+ * @returns {object[]} Matching DB rows
34
+ */
35
+ export function resolveLinks(db, ids, userId) {
36
+ if (!ids.length) return [];
37
+ const unique = [...new Set(ids)];
38
+ const placeholders = unique.map(() => "?").join(",");
39
+ // When userId is defined (hosted mode), scope to that user.
40
+ // When userId is undefined (local mode), no user scoping — all entries accessible.
41
+ const userClause =
42
+ userId !== undefined && userId !== null ? "AND user_id = ?" : "";
43
+ const params =
44
+ userId !== undefined && userId !== null ? [...unique, userId] : unique;
45
+ try {
46
+ return db
47
+ .prepare(
48
+ `SELECT * FROM vault
49
+ WHERE id IN (${placeholders})
50
+ ${userClause}
51
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
52
+ AND superseded_by IS NULL`,
53
+ )
54
+ .all(...params);
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Find all entries that declare `entryId` in their `related_to` field
62
+ * (i.e. entries that point *to* this entry — backlinks).
63
+ * Scoped to the same user. Excludes expired and superseded entries.
64
+ *
65
+ * @param {import('node:sqlite').DatabaseSync} db
66
+ * @param {string} entryId
67
+ * @param {string|null|undefined} userId
68
+ * @returns {object[]} Entries with a backlink to entryId
69
+ */
70
+ export function resolveBacklinks(db, entryId, userId) {
71
+ if (!entryId) return [];
72
+ // When userId is defined (hosted mode), scope to that user.
73
+ // When userId is undefined (local mode), no user scoping — all entries accessible.
74
+ const userClause =
75
+ userId !== undefined && userId !== null ? "AND user_id = ?" : "";
76
+ const likePattern = `%"${entryId}"%`;
77
+ const params =
78
+ userId !== undefined && userId !== null
79
+ ? [likePattern, userId]
80
+ : [likePattern];
81
+ try {
82
+ return db
83
+ .prepare(
84
+ `SELECT * FROM vault
85
+ WHERE related_to LIKE ?
86
+ ${userClause}
87
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
88
+ AND superseded_by IS NULL`,
89
+ )
90
+ .all(...params);
91
+ } catch {
92
+ return [];
93
+ }
94
+ }
95
+
96
+ /**
97
+ * For a set of primary entry IDs, collect all forward links (entries pointed
98
+ * to by `related_to`) and backlinks (entries that point back to any primary).
99
+ *
100
+ * Returns a Map of id → entry row for all linked entries, excluding entries
101
+ * already present in `primaryIds`.
102
+ *
103
+ * @param {import('node:sqlite').DatabaseSync} db
104
+ * @param {object[]} primaryEntries - Full entry rows (must have id + related_to fields)
105
+ * @param {string|null|undefined} userId
106
+ * @returns {{ forward: object[], backward: object[] }}
107
+ */
108
+ export function collectLinkedEntries(db, primaryEntries, userId) {
109
+ const primaryIds = new Set(primaryEntries.map((e) => e.id));
110
+
111
+ // Forward: resolve all IDs from related_to fields
112
+ const forwardIds = [];
113
+ for (const entry of primaryEntries) {
114
+ const ids = parseRelatedTo(entry.related_to);
115
+ for (const id of ids) {
116
+ if (!primaryIds.has(id)) forwardIds.push(id);
117
+ }
118
+ }
119
+ const forwardEntries = resolveLinks(db, forwardIds, userId).filter(
120
+ (e) => !primaryIds.has(e.id),
121
+ );
122
+
123
+ // Backward: find all entries that link to any primary entry
124
+ const backwardSeen = new Set();
125
+ const backwardEntries = [];
126
+ const forwardIds2 = new Set(forwardEntries.map((e) => e.id));
127
+ for (const entry of primaryEntries) {
128
+ const backlinks = resolveBacklinks(db, entry.id, userId);
129
+ for (const bl of backlinks) {
130
+ if (!primaryIds.has(bl.id) && !backwardSeen.has(bl.id)) {
131
+ backwardSeen.add(bl.id);
132
+ backwardEntries.push(bl);
133
+ }
134
+ }
135
+ }
136
+
137
+ return { forward: forwardEntries, backward: backwardEntries };
138
+ }
139
+
140
+ /**
141
+ * Validate a `related_to` value from user input.
142
+ * Must be an array of non-empty strings (ULID-like IDs).
143
+ * Returns an error message string if invalid, or null if valid.
144
+ *
145
+ * @param {unknown} relatedTo
146
+ * @returns {string|null}
147
+ */
148
+ export function validateRelatedTo(relatedTo) {
149
+ if (relatedTo === undefined || relatedTo === null) return null;
150
+ if (!Array.isArray(relatedTo))
151
+ return "related_to must be an array of entry IDs";
152
+ for (const id of relatedTo) {
153
+ if (typeof id !== "string" || !id.trim()) {
154
+ return "each related_to entry must be a non-empty string ID";
155
+ }
156
+ if (id.length > 32) {
157
+ return `related_to ID too long (max 32 chars): "${id.slice(0, 32)}..."`;
158
+ }
159
+ }
160
+ return null;
161
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * migrate-dirs.js — Rename plural vault directories to singular
3
+ *
4
+ * After context-vault >= 2.18.0, kindToDir() returns singular names.
5
+ * Existing vaults still have plural dirs (e.g. knowledge/decisions/).
6
+ * This module plans and executes the rename/merge migration.
7
+ *
8
+ * Architecture: pure planning function + I/O execution function.
9
+ */
10
+
11
+ import {
12
+ existsSync,
13
+ readdirSync,
14
+ mkdirSync,
15
+ renameSync,
16
+ copyFileSync,
17
+ rmSync,
18
+ statSync,
19
+ } from "node:fs";
20
+ import { join, basename } from "node:path";
21
+
22
+ /**
23
+ * Complete plural→singular mapping for vault directory names.
24
+ * Covers the old PLURAL_MAP from files.js plus extended kinds seen in the wild.
25
+ *
26
+ * @type {Record<string, string>}
27
+ */
28
+ export const PLURAL_TO_SINGULAR = {
29
+ // From old PLURAL_MAP in files.js
30
+ insights: "insight",
31
+ decisions: "decision",
32
+ patterns: "pattern",
33
+ statuses: "status",
34
+ analyses: "analysis",
35
+ contacts: "contact",
36
+ projects: "project",
37
+ tools: "tool",
38
+ sources: "source",
39
+ conversations: "conversation",
40
+ messages: "message",
41
+ sessions: "session",
42
+ logs: "log",
43
+ feedbacks: "feedback",
44
+ // Extended kinds from categories.js + observed in vaults
45
+ notes: "note",
46
+ prompts: "prompt",
47
+ documents: "document",
48
+ references: "reference",
49
+ tasks: "task",
50
+ buckets: "bucket",
51
+ architectures: "architecture",
52
+ briefs: "brief",
53
+ companies: "company",
54
+ discoveries: "discovery",
55
+ events: "event",
56
+ ideas: "idea",
57
+ issues: "issue",
58
+ agents: "agent",
59
+ "session-summaries": "session-summary",
60
+ "session-reviews": "session-review",
61
+ "user-prompts": "user-prompt",
62
+ };
63
+
64
+ /**
65
+ * Category directory names that are scanned for plural kind subdirectories.
66
+ */
67
+ const CATEGORY_DIRS = ["knowledge", "entities", "events"];
68
+
69
+ /**
70
+ * Count .md files recursively in a directory.
71
+ *
72
+ * @param {string} dir
73
+ * @returns {number}
74
+ */
75
+ function countMdFiles(dir) {
76
+ let count = 0;
77
+ try {
78
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
79
+ if (entry.isDirectory()) {
80
+ count += countMdFiles(join(dir, entry.name));
81
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
82
+ count++;
83
+ }
84
+ }
85
+ } catch {
86
+ // ignore unreadable dirs
87
+ }
88
+ return count;
89
+ }
90
+
91
+ /**
92
+ * Plan migration operations: walk the vault and detect plural dirs that need renaming.
93
+ * Pure I/O read-only — does not modify any files.
94
+ *
95
+ * @param {string} vaultDir - Absolute path to vault root
96
+ * @returns {MigrationOp[]} Array of planned operations
97
+ *
98
+ * @typedef {{ action: 'rename'|'merge', pluralDir: string, singularDir: string, pluralName: string, singularName: string, fileCount: number }} MigrationOp
99
+ */
100
+ export function planMigration(vaultDir) {
101
+ const ops = [];
102
+
103
+ for (const catName of CATEGORY_DIRS) {
104
+ const catDir = join(vaultDir, catName);
105
+ if (!existsSync(catDir) || !statSync(catDir).isDirectory()) continue;
106
+
107
+ let entries;
108
+ try {
109
+ entries = readdirSync(catDir, { withFileTypes: true });
110
+ } catch {
111
+ continue;
112
+ }
113
+
114
+ for (const entry of entries) {
115
+ if (!entry.isDirectory()) continue;
116
+ const dirName = entry.name;
117
+ const singular = PLURAL_TO_SINGULAR[dirName];
118
+
119
+ // Not a known plural — skip (might already be singular or unknown kind)
120
+ if (!singular) continue;
121
+
122
+ const pluralDir = join(catDir, dirName);
123
+ const singularDir = join(catDir, singular);
124
+
125
+ // Guard: plural and singular are the same (shouldn't happen but be safe)
126
+ if (pluralDir === singularDir) continue;
127
+
128
+ const fileCount = countMdFiles(pluralDir);
129
+ const singularExists = existsSync(singularDir);
130
+
131
+ ops.push({
132
+ action: singularExists ? "merge" : "rename",
133
+ pluralDir,
134
+ singularDir,
135
+ pluralName: dirName,
136
+ singularName: singular,
137
+ fileCount,
138
+ });
139
+ }
140
+ }
141
+
142
+ return ops;
143
+ }
144
+
145
+ /**
146
+ * Copy all files and subdirectories from src into dst (non-overwriting).
147
+ * Mirrors `cp -rn src/* dst/`.
148
+ *
149
+ * @param {string} src
150
+ * @param {string} dst
151
+ */
152
+ function mergeDir(src, dst) {
153
+ mkdirSync(dst, { recursive: true });
154
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
155
+ const srcPath = join(src, entry.name);
156
+ const dstPath = join(dst, entry.name);
157
+ if (entry.isDirectory()) {
158
+ mergeDir(srcPath, dstPath);
159
+ } else if (entry.isFile()) {
160
+ if (!existsSync(dstPath)) {
161
+ copyFileSync(srcPath, dstPath);
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Execute migration operations. Renames or merges plural dirs into singular dirs.
169
+ * Safe to call multiple times — already-renamed dirs produce no-op ops from planMigration.
170
+ *
171
+ * @param {MigrationOp[]} ops - Operations from planMigration()
172
+ * @returns {{ renamed: number, merged: number, errors: string[] }}
173
+ */
174
+ export function executeMigration(ops) {
175
+ let renamed = 0;
176
+ let merged = 0;
177
+ const errors = [];
178
+
179
+ for (const op of ops) {
180
+ try {
181
+ if (op.action === "rename") {
182
+ renameSync(op.pluralDir, op.singularDir);
183
+ renamed++;
184
+ } else {
185
+ // merge: copy files from plural into singular, then remove plural
186
+ mergeDir(op.pluralDir, op.singularDir);
187
+ rmSync(op.pluralDir, { recursive: true, force: true });
188
+ merged++;
189
+ }
190
+ } catch (e) {
191
+ errors.push(`${op.pluralName} → ${op.singularName}: ${e.message}`);
192
+ }
193
+ }
194
+
195
+ return { renamed, merged, errors };
196
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Temporal shortcut resolver.
3
+ *
4
+ * Converts natural-language time expressions to ISO date strings.
5
+ * Fully pure — accepts an explicit `now` Date for deterministic testing.
6
+ *
7
+ * Supported shortcuts (case-insensitive, spaces or underscores):
8
+ * today → start of today (00:00:00 local midnight in UTC)
9
+ * yesterday → { since: start of yesterday, until: end of yesterday }
10
+ * this_week → start of current ISO week (Monday 00:00)
11
+ * last_N_days → N days ago (e.g. last_3_days, last 7 days)
12
+ * last_N_weeks → N weeks ago
13
+ * last_N_months → N calendar months ago (approximate: N × 30 days)
14
+ *
15
+ * Anything that doesn't match a shortcut is returned unchanged so that ISO
16
+ * date strings pass straight through without modification.
17
+ */
18
+
19
+ const SHORTCUT_RE = /^last[_ ](\d+)[_ ](day|days|week|weeks|month|months)$/i;
20
+
21
+ /**
22
+ * Start of today in UTC (i.e. the midnight boundary at which the local date begins,
23
+ * expressed as an ISO string). We operate in UTC throughout so tests are portable.
24
+ *
25
+ * @param {Date} now
26
+ * @returns {Date}
27
+ */
28
+ function startOfToday(now) {
29
+ const d = new Date(now);
30
+ d.setUTCHours(0, 0, 0, 0);
31
+ return d;
32
+ }
33
+
34
+ /**
35
+ * Resolve a single temporal expression to one or two ISO date strings.
36
+ *
37
+ * @param {"since"|"until"} role - Which parameter we are resolving.
38
+ * @param {string} value - Raw parameter value from the caller.
39
+ * @param {Date} [now] - Override for "now" (defaults to `new Date()`).
40
+ * @returns {string} - Resolved ISO date string (or original value if unrecognised).
41
+ */
42
+ export function resolveTemporalShortcut(role, value, now = new Date()) {
43
+ if (!value || typeof value !== "string") return value;
44
+
45
+ const trimmed = value.trim().toLowerCase().replace(/\s+/g, "_");
46
+
47
+ if (trimmed === "today") {
48
+ const start = startOfToday(now);
49
+ if (role === "until") {
50
+ // "until today" means up to end-of-today
51
+ const end = new Date(start);
52
+ end.setUTCDate(end.getUTCDate() + 1);
53
+ return end.toISOString();
54
+ }
55
+ return start.toISOString();
56
+ }
57
+
58
+ if (trimmed === "yesterday") {
59
+ const todayStart = startOfToday(now);
60
+ const yesterdayStart = new Date(todayStart);
61
+ yesterdayStart.setUTCDate(yesterdayStart.getUTCDate() - 1);
62
+ if (role === "since") return yesterdayStart.toISOString();
63
+ // role === "until": end of yesterday = start of today
64
+ return todayStart.toISOString();
65
+ }
66
+
67
+ if (trimmed === "this_week") {
68
+ // Monday 00:00 UTC of the current week
69
+ const todayStart = startOfToday(now);
70
+ const dayOfWeek = todayStart.getUTCDay(); // 0=Sun, 1=Mon, ..., 6=Sat
71
+ const daysFromMonday = (dayOfWeek + 6) % 7; // 0 on Monday
72
+ const monday = new Date(todayStart);
73
+ monday.setUTCDate(monday.getUTCDate() - daysFromMonday);
74
+ if (role === "since") return monday.toISOString();
75
+ // "until this_week" means up to now (end of today)
76
+ const endOfToday = new Date(todayStart);
77
+ endOfToday.setUTCDate(endOfToday.getUTCDate() + 1);
78
+ return endOfToday.toISOString();
79
+ }
80
+
81
+ if (trimmed === "this_month") {
82
+ const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
83
+ if (role === "since") return d.toISOString();
84
+ const endOfMonth = new Date(
85
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1),
86
+ );
87
+ return endOfMonth.toISOString();
88
+ }
89
+
90
+ const m = SHORTCUT_RE.exec(trimmed);
91
+ if (m) {
92
+ const n = parseInt(m[1], 10);
93
+ const unit = m[2].replace(/s$/, ""); // normalise plural → singular
94
+ let ms;
95
+ if (unit === "day") {
96
+ ms = n * 86400000;
97
+ } else if (unit === "week") {
98
+ ms = n * 7 * 86400000;
99
+ } else {
100
+ // month → approximate as 30 days
101
+ ms = n * 30 * 86400000;
102
+ }
103
+ const target = new Date(now.getTime() - ms);
104
+ target.setUTCHours(0, 0, 0, 0);
105
+ return target.toISOString();
106
+ }
107
+
108
+ // Unrecognised — pass through unchanged (ISO dates, empty strings, etc.)
109
+ return value;
110
+ }
111
+
112
+ /**
113
+ * Resolve both `since` and `until` parameters, handling the special case where
114
+ * "yesterday" sets both bounds automatically when only `since` is specified.
115
+ *
116
+ * Returns `{ since, until }` with resolved ISO strings (or originals).
117
+ *
118
+ * @param {{ since?: string, until?: string }} params
119
+ * @param {Date} [now]
120
+ * @returns {{ since: string|undefined, until: string|undefined }}
121
+ */
122
+ export function resolveTemporalParams(params, now = new Date()) {
123
+ let { since, until } = params;
124
+
125
+ // Special case: "yesterday" on `since` without an explicit `until`
126
+ // auto-fills `until` to the end of yesterday.
127
+ if (
128
+ since?.trim().toLowerCase() === "yesterday" &&
129
+ (until === undefined || until === null)
130
+ ) {
131
+ since = resolveTemporalShortcut("since", since, now);
132
+ until = resolveTemporalShortcut("until", "yesterday", now);
133
+ return { since, until };
134
+ }
135
+
136
+ return {
137
+ since:
138
+ since !== undefined
139
+ ? resolveTemporalShortcut("since", since, now)
140
+ : since,
141
+ until:
142
+ until !== undefined
143
+ ? resolveTemporalShortcut("until", until, now)
144
+ : until,
145
+ };
146
+ }