decant-core 1.13.0 → 1.14.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/ai/claude.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ChatParser } from "./base.js";
2
2
  import { convertToMarkdown } from "../utils/html-to-markdown.js";
3
+ import { pickTimestamp } from "../utils/timestamps.js";
3
4
 
4
5
  async function getOrganizationId() {
5
6
  try {
@@ -736,6 +737,13 @@ export class ClaudeParser extends ChatParser {
736
737
  if (thinkingStr) {
737
738
  msgObj.thinking = thinkingStr;
738
739
  }
740
+ const timestamp = pickTimestamp(message, [
741
+ "created_at",
742
+ "updated_at",
743
+ ]);
744
+ if (timestamp) {
745
+ msgObj.timestamp = timestamp;
746
+ }
739
747
  messages.push(msgObj);
740
748
  }
741
749
 
@@ -760,6 +768,14 @@ export class ClaudeParser extends ChatParser {
760
768
  messages.push({
761
769
  role: "Claude Artifact",
762
770
  content: artContent.trim(),
771
+ ...(pickTimestamp(message, ["created_at", "updated_at"])
772
+ ? {
773
+ timestamp: pickTimestamp(message, [
774
+ "created_at",
775
+ "updated_at",
776
+ ]),
777
+ }
778
+ : {}),
763
779
  });
764
780
  }
765
781
  }
@@ -912,7 +928,36 @@ export class ClaudeParser extends ChatParser {
912
928
  }
913
929
 
914
930
  if (content) {
915
- messages.push({ role, content });
931
+ const msgObj = { role, content };
932
+ // Best-effort DOM timestamp. <time datetime> usually lives in the
933
+ // sibling MessageActions toolbar inside the same transcript row —
934
+ // not inside the message element itself. Scope to the closest row
935
+ // so we never borrow the previous/next turn's timestamp. Sparse in
936
+ // practice (hover-only on some turns) — absent stays absent.
937
+ try {
938
+ let timeEl =
939
+ typeof el.querySelector === "function"
940
+ ? el.querySelector("time[datetime]")
941
+ : null;
942
+ if (!timeEl && typeof el.closest === "function") {
943
+ // Narrow containers only: transcript-row holds one turn, so its
944
+ // time belongs to this message. Never fall back to broad
945
+ // containers like article (many turns) — wrong date is worse
946
+ // than no date.
947
+ const row = el.closest(
948
+ '[data-testid="transcript-row"], .group\\/message-row',
949
+ );
950
+ timeEl = row?.querySelector?.("time[datetime]") || null;
951
+ }
952
+ const datetime = timeEl?.getAttribute?.("datetime");
953
+ const timestamp = pickTimestamp({ datetime }, ["datetime"]);
954
+ if (timestamp) {
955
+ msgObj.timestamp = timestamp;
956
+ }
957
+ } catch {
958
+ // Ignore DOM timestamp lookup errors
959
+ }
960
+ messages.push(msgObj);
916
961
  }
917
962
  }
918
963
 
package/ai/deepseek.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ChatParser } from "./base.js";
2
2
  import { convertToMarkdown } from "../utils/html-to-markdown.js";
3
+ import { normalizeTimestamp } from "../utils/timestamps.js";
3
4
 
4
5
  function getUserToken() {
5
6
  try {
@@ -120,6 +121,12 @@ async function fetchDeepSeekConversation(sessionId, token) {
120
121
  if (thinking) {
121
122
  msg.thinking = thinking;
122
123
  }
124
+ // Turn granularity: inserted_at is seconds-epoch, ~identical within a
125
+ // USER/ASSISTANT pair, so both sides share the turn's timestamp.
126
+ const timestamp = normalizeTimestamp(msgNode?.inserted_at);
127
+ if (timestamp) {
128
+ msg.timestamp = timestamp;
129
+ }
123
130
  return msg;
124
131
  })
125
132
  .filter((msg) => msg.content.length > 0);
package/ai/meta.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ChatParser } from "./base.js";
2
2
  import { convertToMarkdown } from "../utils/html-to-markdown.js";
3
+ import { pickTimestamp } from "../utils/timestamps.js";
3
4
 
4
5
  // Observed Relay doc_ids for the conversation message list (Sept 2026).
5
6
  // These rotate when Meta redeploys the web client. The parser tries the
@@ -139,12 +140,23 @@ export function formatMetaEdges(edges) {
139
140
  });
140
141
  const messages = [];
141
142
  for (const node of nodes) {
143
+ // Turn granularity: user + assistant in the same turn share createdAt,
144
+ // so both messages carry the same timestamp (matches Perplexity pattern).
145
+ const timestamp = pickTimestamp(node, ["createdAt", "userCreatedAt"]);
142
146
  if (node.__typename === "UserMessage") {
143
147
  const content = extractMetaUserText(node);
144
- if (content) messages.push({ role: "User", content });
148
+ if (content) {
149
+ const msg = { role: "User", content };
150
+ if (timestamp) msg.timestamp = timestamp;
151
+ messages.push(msg);
152
+ }
145
153
  } else if (node.__typename === "AssistantMessage") {
146
154
  const content = extractMetaAssistantText(node);
147
- if (content) messages.push({ role: "Meta AI", content });
155
+ if (content) {
156
+ const msg = { role: "Meta AI", content };
157
+ if (timestamp) msg.timestamp = timestamp;
158
+ messages.push(msg);
159
+ }
148
160
  }
149
161
  }
150
162
  return messages;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decant-core",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "A shared web extraction layer for AI conversations and regular web pages.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Shared per-message timestamp normalization (decant-core).
3
+ *
4
+ * Parsers attach `timestamp` as ISO strings, locale strings, or numeric
5
+ * epochs (seconds or milliseconds). Display layers (ace) format whatever
6
+ * string they receive, so this helper keeps ISO/locale strings as-is and
7
+ * converts numeric epochs to ISO.
8
+ *
9
+ * Turn-granularity sources (Perplexity entries, Meta edges, DeepSeek pairs)
10
+ * reuse the same timestamp for both prompt and response — callers pass the
11
+ * same value to both messages.
12
+ */
13
+
14
+ /** Epoch values with abs < 1e11 are treated as seconds, else milliseconds. */
15
+ export function normalizeEpochToMs(value) {
16
+ if (Math.abs(value) < 1e11) return value * 1000;
17
+ return value;
18
+ }
19
+
20
+ /**
21
+ * Normalizes a raw timestamp to a display-ready string.
22
+ * @param {unknown} value ISO string, locale string, epoch number, or numeric string.
23
+ * @returns {string|null} ISO string for epochs, trimmed input for date strings, else null.
24
+ */
25
+ export function normalizeTimestamp(value) {
26
+ if (typeof value === "number" && Number.isFinite(value)) {
27
+ try {
28
+ return new Date(normalizeEpochToMs(value)).toISOString();
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+ if (typeof value === "string") {
34
+ const trimmed = value.trim();
35
+ if (!trimmed) return null;
36
+ if (/^[+-]?\d+(\.\d+)?$/.test(trimmed)) {
37
+ const numeric = Number(trimmed);
38
+ if (Number.isFinite(numeric)) {
39
+ try {
40
+ return new Date(normalizeEpochToMs(numeric)).toISOString();
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+ return trimmed;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Picks the first available timestamp from candidate fields.
54
+ * @param {object} source Parser payload object.
55
+ * @param {string[]} keys Field names to try in order.
56
+ * @returns {string|null}
57
+ */
58
+ export function pickTimestamp(source, keys) {
59
+ if (!source || !Array.isArray(keys)) return null;
60
+ for (const key of keys) {
61
+ const normalized = normalizeTimestamp(source[key]);
62
+ if (normalized) return normalized;
63
+ }
64
+ return null;
65
+ }