@cap-js/agents 0.0.0 → 0.9.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.
Files changed (56) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +155 -0
  3. package/_i18n/messages.properties +31 -0
  4. package/cds-plugin.js +140 -0
  5. package/index.cds +105 -0
  6. package/index.js +0 -0
  7. package/lib/agents/markdown/backends/mime-utils.js +37 -0
  8. package/lib/agents/markdown/backends/outputs-backend.js +152 -0
  9. package/lib/agents/markdown/backends/uploads-backend.js +143 -0
  10. package/lib/agents/markdown/deep-agent.js +93 -0
  11. package/lib/agents/middleware/agent-actions.js +18 -0
  12. package/lib/agents/middleware/content-filter.js +191 -0
  13. package/lib/agents/middleware/hitl-edit-note-injector.js +18 -0
  14. package/lib/agents/middleware/hitl.js +20 -0
  15. package/lib/agents/middleware/index.js +19 -0
  16. package/lib/agents/middleware/patch-tool-calls.js +51 -0
  17. package/lib/agents/middleware/quota-enforcer.js +94 -0
  18. package/lib/agents/middleware/status-update.js +153 -0
  19. package/lib/agents/middleware/tool-selection.js +22 -0
  20. package/lib/agents/quota-enforcer-at-start.js +198 -0
  21. package/lib/agents/summarize-on-timeout.js +91 -0
  22. package/lib/compile.js +55 -0
  23. package/lib/index.cjs +1 -0
  24. package/lib/index.js +422 -0
  25. package/lib/models/aicore.js +441 -0
  26. package/lib/models/anthropic.js +77 -0
  27. package/lib/models/mock.js +88 -0
  28. package/lib/preview/chat.html +875 -0
  29. package/lib/preview/preview.js +46 -0
  30. package/lib/protocol/agent-card.js +297 -0
  31. package/lib/protocol/persistence/checkpoint-saver.js +317 -0
  32. package/lib/protocol/persistence/file-store.js +209 -0
  33. package/lib/protocol/persistence/push-notification-store.js +59 -0
  34. package/lib/protocol/persistence/task-store.js +47 -0
  35. package/lib/protocol/push-notification-sender.js +57 -0
  36. package/lib/sidecar.js +162 -0
  37. package/lib/telemetry/active-users.js +106 -0
  38. package/lib/telemetry/chat-tracing.js +342 -0
  39. package/lib/telemetry/metrics.js +85 -0
  40. package/lib/telemetry/mlflow.js +290 -0
  41. package/lib/telemetry/tool-tracing.js +164 -0
  42. package/lib/telemetry/tracing.js +150 -0
  43. package/lib/utils/inner-auth.js +33 -0
  44. package/lib/utils/markdown.js +199 -0
  45. package/lib/utils/message-handling.js +155 -0
  46. package/lib/utils/utils.js +168 -0
  47. package/package.json +225 -2
  48. package/srv/graph-cache.js +82 -0
  49. package/srv/handlers/graph-executor.js +1369 -0
  50. package/srv/handlers/index.js +173 -0
  51. package/srv/handlers/mcp-tools.js +159 -0
  52. package/srv/handlers/sub-agent-tools.js +314 -0
  53. package/srv/handlers/system-prompt.js +25 -0
  54. package/srv/handlers/tools.js +366 -0
  55. package/srv/langgraph-executor-srv.js +70 -0
  56. package/srv/push-notification-srv.js +149 -0
@@ -0,0 +1,199 @@
1
+ import cds from "@sap/cds"
2
+ const { path, fs } = cds.utils
3
+ const LOG = cds.log("agents")
4
+
5
+ /**
6
+ * Mirrors CAP's internal slug rules used for service path generation.
7
+ */
8
+ export const slugified = (name) =>
9
+ /[^.]+$/
10
+ .exec(name)[0]
11
+ .replace(/Service$/, "")
12
+ .replace(/_/g, "-")
13
+ .replace(/([a-z0-9])([A-Z])/g, (_m, c, C) => c + "-" + C)
14
+ .toLowerCase()
15
+
16
+ /**
17
+ * Absolute filesystem directory of the `.cds` source file that defines `srv`.
18
+ */
19
+ export function serviceSourceDir(srv) {
20
+ const file = srv?.definition?.$location?.file
21
+ if (!file) return undefined
22
+ // $location.file is relative to cds.root
23
+ return path.dirname(path.join(cds.root, file))
24
+ }
25
+
26
+ /**
27
+ * Get the effective service definition (feature-toggled if available, else base).
28
+ */
29
+ function effectiveDefinition(srv) {
30
+ return cds.context?.model?.definitions?.[srv.name] || srv?.definition
31
+ }
32
+
33
+ /**
34
+ * Resolve a path from a `@agent.directory` / `@agent.card` annotation against the
35
+ * `.cds` source file's directory. Absolute paths are returned as-is.
36
+ */
37
+ function resolveAnnotatedPath(srv, annotationValue) {
38
+ if (!annotationValue || typeof annotationValue !== "string") return undefined
39
+ if (path.isAbsolute(annotationValue)) return annotationValue
40
+ const srcDir = serviceSourceDir(srv)
41
+ if (!srcDir) return undefined
42
+ return path.resolve(srcDir, annotationValue)
43
+ }
44
+
45
+ /**
46
+ * Resolve the agent directory for a service.
47
+ *
48
+ * Resolution order (all paths relative to the `.cds` source file dir):
49
+ * 1. `@agent.directory` annotation (relative to `.cds` source file dir, or absolute)
50
+ * 2. `<srcDir>` itself — when the service `.cds` file lives inside its own dedicated
51
+ * folder and `AGENTS.md` is co-located there (e.g. `srv/travel-agent/service.cds`
52
+ * + `srv/travel-agent/AGENTS.md`).
53
+ * 3. `<srcDir>/<slug-with-agent-suffix>/` — sibling directory.
54
+ * The slug gets `-agent` appended if it does not already end in `-agent`,
55
+ * e.g. `TravelService` → `./travel-agent/`, `ProductAgentService` → `./product-agent/`.
56
+ * 4. `<srcDir>/agents/<base>/` — collected under an `agents/` parent.
57
+ * The base is the slug with any trailing `-agent` stripped,
58
+ * e.g. `TravelService` → `./agents/travel/`, `ProductAgentService` → `./agents/product/`.
59
+ * 5. `undefined`
60
+ */
61
+ export function resolveAgentDir(srv) {
62
+ const def = effectiveDefinition(srv)
63
+ const annotated = resolveAnnotatedPath(srv, def?.["@agent.directory"])
64
+ // 1. `@agent.directory`
65
+ if (annotated) {
66
+ if (fs.existsSync(annotated)) return annotated
67
+ LOG.warn("@agent.directory path not found, falling back to convention", {
68
+ service: srv?.name,
69
+ annotation: def?.["@agent.directory"],
70
+ resolved: annotated,
71
+ })
72
+ return undefined
73
+ }
74
+ const srcDir = serviceSourceDir(srv)
75
+ if (!srcDir) return undefined
76
+
77
+ // 2. <srcDir> itself — AGENTS.md co-located with the .cds file
78
+ if (fs.existsSync(path.join(srcDir, "AGENTS.md"))) return srcDir
79
+
80
+ const slug = slugified(srv.name)
81
+ const agentSlug = slug.endsWith("-agent") ? slug : `${slug}-agent`
82
+ const base = slug.endsWith("-agent") ? slug.slice(0, -"-agent".length) : slug
83
+
84
+ // 3. <srcDir>/<agentSlug>/
85
+ const sibling = path.join(srcDir, agentSlug)
86
+ if (fs.existsSync(sibling)) return sibling
87
+
88
+ // 4. <srcDir>/agents/<base>/
89
+ if (base) {
90
+ const grouped = path.join(srcDir, "agents", base)
91
+ if (fs.existsSync(grouped)) return grouped
92
+ }
93
+
94
+ return undefined
95
+ }
96
+
97
+ /**
98
+ * Resolve an explicit agent-card markdown file via `@agent.card` annotation.
99
+ * Returns `undefined` when no annotation is set; falls through to the
100
+ * agent-dir-based resolution chain (`AGENT_CARD.md` → `skills/`) handled by
101
+ * the card builder.
102
+ */
103
+ export function resolveAgentCardPath(srv) {
104
+ const def = effectiveDefinition(srv)
105
+ return resolveAnnotatedPath(srv, def?.["@agent.card"])
106
+ }
107
+
108
+ /**
109
+ * `<dir>/AGENTS.md` exists ⇒ this is a deep-agent directory.
110
+ */
111
+ export function isDeepAgentDir(dir) {
112
+ if (!dir) return false
113
+ return fs.existsSync(path.join(dir, "AGENTS.md"))
114
+ }
115
+
116
+ /**
117
+ * Parse YAML frontmatter from a markdown file.
118
+ */
119
+ export function parseFrontmatter(filePath) {
120
+ let content
121
+ try {
122
+ content = fs.readFileSync(filePath, "utf8")
123
+ } catch {
124
+ return null
125
+ }
126
+
127
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)
128
+ if (!match) return null
129
+
130
+ try {
131
+ const { yaml } = cds.utils
132
+ return yaml.parse(match[1])
133
+ } catch {
134
+ // Fallback: return null if YAML parsing fails
135
+ return null
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Scan a skills directory and return skill metadata for the agent card.
141
+ * Reads SKILL.md frontmatter per agentskills.io spec.
142
+ * Respects metadata.private: true to exclude internal skills.
143
+ *
144
+ * @returns {Array<{id, name, description, tags, examples}>}
145
+ */
146
+ export function scanSkills(skillsDir) {
147
+ if (!fs.existsSync(skillsDir)) return []
148
+
149
+ let entries
150
+ try {
151
+ entries = fs.readdirSync(skillsDir, { withFileTypes: true })
152
+ } catch {
153
+ return []
154
+ }
155
+
156
+ const skills = []
157
+ for (const entry of entries) {
158
+ if (!entry.isDirectory()) continue
159
+ const skillFile = path.join(skillsDir, entry.name, "SKILL.md")
160
+ const meta = parseFrontmatter(skillFile)
161
+ if (!meta || !meta.name) continue
162
+
163
+ // metadata.private: true → internal skill, not in agent card
164
+ const isPrivate = meta.metadata?.private === "true" || meta.metadata?.private === true
165
+ if (isPrivate) continue
166
+
167
+ skills.push({
168
+ id: meta.name,
169
+ name: meta.name.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
170
+ description: (meta.description || "").trim(),
171
+ tags: meta.metadata?.tags || [],
172
+ examples: meta.metadata?.examples || [],
173
+ })
174
+ }
175
+
176
+ // Alphabetical sort for deterministic output across filesystems
177
+ return skills.sort((a, b) => a.id.localeCompare(b.id))
178
+ }
179
+
180
+ /**
181
+ * Parse AGENTS.md frontmatter for agent-level metadata.
182
+ */
183
+ export function parseAgentMetadata(agentDir) {
184
+ return parseFrontmatter(path.join(agentDir, "AGENTS.md"))
185
+ }
186
+
187
+ /**
188
+ * Parse AGENT_CARD.md for explicit agent card definition (convention within agentDir).
189
+ */
190
+ export function parseAgentCardMd(agentDir) {
191
+ return parseFrontmatter(path.join(agentDir, "AGENT_CARD.md"))
192
+ }
193
+
194
+ /**
195
+ * Parse an agent card markdown file at an explicit absolute path.
196
+ */
197
+ export function parseAgentCardFile(absolutePath) {
198
+ return parseFrontmatter(absolutePath)
199
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * A2A message part utilities — handle both wire formats:
3
+ *
4
+ * v0.3.x (kind-based): {kind:"text",text} | {kind:"file",file:{bytes|uri,mimeType}} | {kind:"data",data}
5
+ * v1.0 (oneOf): {content:{$case:"text"|"raw"|"url"|"data", value}, mediaType, filename}
6
+ */
7
+
8
+ /**
9
+ * Extract plain text from A2A parts. Data parts → JSON.stringify. File parts skipped.
10
+ * @param {Array} parts
11
+ * @returns {string}
12
+ */
13
+ export function partsToText(parts = []) {
14
+ return (
15
+ parts
16
+ .map((p) => {
17
+ if (p.content?.$case === "text") return p.content.value || ""
18
+ if (p.content?.$case === "data") return JSON.stringify(p.content.value)
19
+ // REVISIT: remove when @a2a-js/sdk pinned to ^1.x (SDK normalizes to content.$case)
20
+ if (p.kind === "text" || (!p.kind && p.text)) return p.text || ""
21
+ if (p.kind === "data" && p.data !== undefined) return JSON.stringify(p.data)
22
+ return null
23
+ })
24
+ .filter((v) => v !== null)
25
+ .join(" ") || ""
26
+ )
27
+ }
28
+
29
+ /**
30
+ * Return the raw `data` object of the FIRST DataPart in `parts`, or undefined if none.
31
+ * Handles both wire shapes (v0.3 kind-based, v1.0 oneOf); returned as-is (no clone/validation).
32
+ * @param {Array} parts
33
+ * @returns {object | undefined}
34
+ */
35
+ export function firstDataPart(parts = []) {
36
+ for (const p of parts) {
37
+ // Nullish payloads fall through to the text parser (spec: `data` is always an object).
38
+ if (p.content?.$case === "data" && p.content.value != null) return p.content.value
39
+ // REVISIT: remove when @a2a-js/sdk pinned to ^1.x (SDK normalizes to content.$case)
40
+ if (p.kind === "data" && p.data != null) return p.data
41
+ }
42
+ return undefined
43
+ }
44
+
45
+ const IMAGE_MIME_RE = /^image\//
46
+
47
+ /**
48
+ * Map A2A parts to LangChain-JS / OpenAI content value for MLflow span inputs.
49
+ *
50
+ * Single text part → plain string. Otherwise array of content blocks:
51
+ * text/data → {type:"text", text}
52
+ * image/* → {type:"image_url", image_url:{url:"data:<mime>;base64,..."|"<uri>"}}
53
+ * other files → {type:"text", text:"[file: <name> (<mime>)]"}
54
+ *
55
+ * @param {Array} parts
56
+ * @returns {string | Array}
57
+ */
58
+ export function partsToMessageContent(parts = []) {
59
+ const blocks = []
60
+ for (const p of parts) {
61
+ // v1.0 oneOf
62
+ if (p.content?.$case) {
63
+ switch (p.content.$case) {
64
+ case "text":
65
+ blocks.push({ type: "text", text: p.content.value || "" })
66
+ break
67
+ case "url": {
68
+ const mime = p.mediaType || ""
69
+ if (IMAGE_MIME_RE.test(mime)) {
70
+ blocks.push({ type: "image_url", image_url: { url: p.content.value } })
71
+ } else {
72
+ const label = p.filename
73
+ ? `[file: ${p.filename} (${mime || "unknown"})]`
74
+ : p.content.value
75
+ blocks.push({ type: "text", text: label })
76
+ }
77
+ break
78
+ }
79
+ case "raw": {
80
+ const mime = p.mediaType || "application/octet-stream"
81
+ const b64 = Buffer.isBuffer(p.content.value)
82
+ ? p.content.value.toString("base64")
83
+ : p.content.value
84
+ if (IMAGE_MIME_RE.test(mime)) {
85
+ blocks.push({ type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } })
86
+ } else {
87
+ blocks.push({ type: "text", text: `[file: ${p.filename || "attachment"} (${mime})]` })
88
+ }
89
+ break
90
+ }
91
+ case "data":
92
+ blocks.push({ type: "text", text: JSON.stringify(p.content.value, null, 2) })
93
+ break
94
+ default:
95
+ break
96
+ }
97
+ continue
98
+ }
99
+ // REVISIT: remove when @a2a-js/sdk pinned to ^1.x (SDK normalizes to content.$case)
100
+ // v0.3.x kind-based
101
+ if (p.kind === "text" || (!p.kind && p.text)) {
102
+ blocks.push({ type: "text", text: p.text || "" })
103
+ } else if (p.kind === "file") {
104
+ const f = p.file || p
105
+ const mime = f.mimeType || "application/octet-stream"
106
+ if (f.bytes) {
107
+ if (IMAGE_MIME_RE.test(mime)) {
108
+ blocks.push({ type: "image_url", image_url: { url: `data:${mime};base64,${f.bytes}` } })
109
+ } else {
110
+ blocks.push({ type: "text", text: `[file: ${f.name || "attachment"} (${mime})]` })
111
+ }
112
+ } else if (f.uri) {
113
+ if (IMAGE_MIME_RE.test(mime)) {
114
+ blocks.push({ type: "image_url", image_url: { url: f.uri } })
115
+ } else {
116
+ blocks.push({ type: "text", text: `[file: ${f.name || f.uri} (${mime})]` })
117
+ }
118
+ }
119
+ } else if (p.kind === "data" && p.data !== undefined) {
120
+ blocks.push({ type: "text", text: JSON.stringify(p.data, null, 2) })
121
+ }
122
+ }
123
+ if (blocks.length === 0) return ""
124
+ if (blocks.length === 1 && blocks[0].type === "text") return blocks[0].text
125
+ return blocks
126
+ }
127
+
128
+ /**
129
+ * Build OpenAI-format chat messages from A2A conversation history + current message.
130
+ *
131
+ * A2A roles → OpenAI roles:
132
+ * "user" | "ROLE_USER" | 1 → "user"
133
+ * "agent" | "ROLE_AGENT" | 2 → "assistant"
134
+ *
135
+ * Uses history when available (SDK appends current message to it before execute()),
136
+ * falls back to current message only for new tasks.
137
+ *
138
+ * @param {object} requestContext
139
+ * @returns {Array<{role:string, content:string|Array}>}
140
+ */
141
+ export function buildChatMessages(requestContext) {
142
+ const history = requestContext.task?.history || []
143
+ const current = requestContext.userMessage
144
+ const a2aMessages = history.length > 0 ? history : [current]
145
+
146
+ function toOaiRole(role) {
147
+ if (role === "agent" || role === "ROLE_AGENT" || role === 2) return "assistant"
148
+ return "user"
149
+ }
150
+
151
+ return a2aMessages.filter(Boolean).map((msg) => ({
152
+ role: toOaiRole(msg.role),
153
+ content: partsToMessageContent(msg.parts || []),
154
+ }))
155
+ }
@@ -0,0 +1,168 @@
1
+ import cds from "@sap/cds"
2
+ import { domainToASCII } from "node:url"
3
+
4
+ function resolveI18n(value, locale) {
5
+ if (!value) return undefined
6
+ const match = /{i18n>([^}]+)}/.exec(value)
7
+ if (match) {
8
+ return cds.i18n?.labels?.texts4?.(locale)?.[match[1]] || value
9
+ }
10
+ return value
11
+ }
12
+
13
+ export function getDescription(obj, locale) {
14
+ locale = locale || cds.context?.locale || "en"
15
+
16
+ const title =
17
+ cds.i18n?.labels?.at(obj, locale) ||
18
+ resolveI18n(obj["@Common.Label"], locale) ||
19
+ resolveI18n(obj["@title"], locale)
20
+
21
+ const description =
22
+ resolveI18n(obj["@Core.Description"], locale) || resolveI18n(obj["@description"], locale)
23
+
24
+ const longDescription = resolveI18n(obj["@Core.LongDescription"], locale)
25
+
26
+ const parts = [title, description].filter(Boolean)
27
+ if (parts.length === 0 && !longDescription) return obj.doc || undefined
28
+
29
+ let result = parts.join("\n")
30
+
31
+ if (longDescription) {
32
+ result = result ? `${result}\n\n${longDescription}` : longDescription
33
+ }
34
+
35
+ return result || undefined
36
+ }
37
+
38
+ export function getFilteredEntities(srv) {
39
+ return Object.fromEntries(
40
+ Object.entries(srv.entities || {})
41
+ .filter(
42
+ ([name, entity]) =>
43
+ !(entity["@cds.autoexposed"] && !entity["@cds.autoexpose"]) &&
44
+ !name.endsWith("DraftAdministrativeData") &&
45
+ !name.endsWith(".texts") &&
46
+ !entity["@cds.api.ignore"],
47
+ )
48
+ .sort(([a], [b]) => a.localeCompare(b)),
49
+ )
50
+ }
51
+
52
+ export function getFilteredActions(srv) {
53
+ const actions = {}
54
+ for (const op of srv.actions || []) {
55
+ if ((op.kind === "action" || op.kind === "function") && !op["@cds.api.ignore"]) {
56
+ const localName = op.name.split(".").pop()
57
+ actions[localName] = op
58
+ }
59
+ }
60
+ return actions
61
+ }
62
+
63
+ /**
64
+ * Sanitize an agent name into a valid LangChain/LLM tool name.
65
+ */
66
+ export function toolName(rawName) {
67
+ return rawName
68
+ .replace(/[^a-zA-Z0-9_-]/g, "_")
69
+ .toLowerCase()
70
+ .replace(/^[^a-z]/, (c) => `_${c}`) // ensure first char is a letter (some LLM APIs reject otherwise)
71
+ .slice(0, 60)
72
+ }
73
+
74
+ /**
75
+ * Shorten a UUID/ID to first 8 characters for log readability.
76
+ */
77
+ export function short(id) {
78
+ return id?.slice(0, 8) || "-"
79
+ }
80
+
81
+ /**
82
+ * Emit an audit log event (fire-and-forget).
83
+ * All events are mapped to SecurityEvent for compatibility with SAP Audit Log Service.
84
+ * The original event name is preserved in data.data.event for forensic analysis.
85
+ * Includes cds.context.id as correlationId for cross-referencing with auto-emitted
86
+ * DPP events (e.g., SensitiveDataRead triggered by tool entity access).
87
+ * Never blocks execution. Logs warning on failure.
88
+ */
89
+ export function audit(event, data) {
90
+ if (!cds.requires["audit-log"]) return
91
+ cds.connect
92
+ .to("audit-log")
93
+ .then((a) =>
94
+ a.log("SecurityEvent", {
95
+ data: { event, correlationId: cds.context?.id, ...data.data },
96
+ ip: data.ip,
97
+ }),
98
+ )
99
+ .catch((err) => {
100
+ const LOG = cds.log("agents|audit")
101
+ LOG.warn("audit emit failed", { event, error: err.message })
102
+ })
103
+ }
104
+
105
+ const ALLOWED_PROTOCOLS = ["http:", "https:"]
106
+
107
+ /**
108
+ * Validates that a URL belongs to one of the allowed domains.
109
+ * Handles subdomains, punycode normalization, protocol validation.
110
+ * Returns true if URL's hostname matches or is a subdomain of any allowed domain.
111
+ * Returns false for invalid URLs, non-HTTP(S) protocols, or domain mismatch.
112
+ */
113
+ export function isAllowedDomain(untrustedUrl, allowedDomains) {
114
+ if (!untrustedUrl || !allowedDomains?.length) return false
115
+
116
+ let hostname
117
+ try {
118
+ const parsed = new URL(untrustedUrl)
119
+ if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) return false
120
+ hostname = parsed.hostname
121
+ if (!hostname) return false
122
+ } catch {
123
+ return false
124
+ }
125
+
126
+ for (const domain of allowedDomains) {
127
+ const normalized = domainToASCII(domain)
128
+ if (!normalized) continue
129
+ if (hostname === normalized) return true
130
+ if (hostname.endsWith("." + normalized)) return true
131
+ }
132
+
133
+ return false
134
+ }
135
+
136
+ export const ms4 =
137
+ cds.utils.ms4 ||
138
+ ((val) => {
139
+ if (typeof val === "number") return val
140
+ const m = /^(\d+)\s*(ms|s|sec|seconds?|m|min|minutes?|h|hrs|hours?|d|days?|w|weeks?)?$/i.exec(
141
+ String(val),
142
+ )
143
+ if (!m) return Number(val) || 0
144
+ const n = Number(m[1])
145
+ const unit = (m[2] || "ms").toLowerCase()
146
+ const factors = {
147
+ ms: 1,
148
+ s: 1000,
149
+ sec: 1000,
150
+ second: 1000,
151
+ seconds: 1000,
152
+ m: 60000,
153
+ min: 60000,
154
+ minute: 60000,
155
+ minutes: 60000,
156
+ h: 3600000,
157
+ hrs: 3600000,
158
+ hour: 3600000,
159
+ hours: 3600000,
160
+ d: 86400000,
161
+ day: 86400000,
162
+ days: 86400000,
163
+ w: 604800000,
164
+ week: 604800000,
165
+ weeks: 604800000,
166
+ }
167
+ return n * (factors[unit] || 1)
168
+ })