thread-archive 0.0.2__py3-none-any.whl

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 (132) hide show
  1. thread_archive/__init__.py +34 -0
  2. thread_archive/_api.py +2235 -0
  3. thread_archive/_config.py +126 -0
  4. thread_archive/_importers/__init__.py +81 -0
  5. thread_archive/_importers/_continuation.py +136 -0
  6. thread_archive/_importers/_cursor.py +103 -0
  7. thread_archive/_importers/_events.py +244 -0
  8. thread_archive/_importers/_line_stream.py +193 -0
  9. thread_archive/_importers/_read.py +82 -0
  10. thread_archive/_importers/_result.py +17 -0
  11. thread_archive/_importers/_sidecar.py +113 -0
  12. thread_archive/_importers/_state.py +240 -0
  13. thread_archive/_importers/_titles.py +179 -0
  14. thread_archive/_importers/antigravity.py +254 -0
  15. thread_archive/_importers/claude_code.py +293 -0
  16. thread_archive/_importers/claude_science.py +330 -0
  17. thread_archive/_importers/cloth.py +20 -0
  18. thread_archive/_importers/codex.py +324 -0
  19. thread_archive/_importers/cowork.py +107 -0
  20. thread_archive/_importers/cursor.py +432 -0
  21. thread_archive/_importers/exports.py +389 -0
  22. thread_archive/_importers/grok.py +600 -0
  23. thread_archive/_importers/opencode.py +538 -0
  24. thread_archive/_knowledge/__init__.py +67 -0
  25. thread_archive/_knowledge/_claims.py +116 -0
  26. thread_archive/_knowledge/_community.py +75 -0
  27. thread_archive/_knowledge/graph.py +208 -0
  28. thread_archive/_knowledge/materialize.py +212 -0
  29. thread_archive/_knowledge/write.py +438 -0
  30. thread_archive/_launchd.py +167 -0
  31. thread_archive/_mcp/__init__.py +1 -0
  32. thread_archive/_mcp/librarian.py +151 -0
  33. thread_archive/_mcp/server.py +307 -0
  34. thread_archive/_retrieval/__init__.py +280 -0
  35. thread_archive/_retrieval/_classify.py +80 -0
  36. thread_archive/_retrieval/_codex.py +157 -0
  37. thread_archive/_retrieval/_context.py +123 -0
  38. thread_archive/_retrieval/_extract.py +184 -0
  39. thread_archive/_retrieval/embed.py +186 -0
  40. thread_archive/_retrieval/format.py +149 -0
  41. thread_archive/_retrieval/fts.py +538 -0
  42. thread_archive/_retrieval/rank.py +228 -0
  43. thread_archive/_retrieval/read.py +908 -0
  44. thread_archive/_retrieval/rerank.py +143 -0
  45. thread_archive/_retrieval/vectors.py +611 -0
  46. thread_archive/_scripts/__init__.py +1 -0
  47. thread_archive/_scripts/backfill_recompute.py +328 -0
  48. thread_archive/_scripts/backfill_reconcile.py +296 -0
  49. thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
  50. thread_archive/_scripts/recover_dropped_events.py +190 -0
  51. thread_archive/_scripts/repair_grok_tool_names.py +118 -0
  52. thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
  53. thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
  54. thread_archive/_setup/__init__.py +12 -0
  55. thread_archive/_setup/clients.py +89 -0
  56. thread_archive/_setup/wizard.py +474 -0
  57. thread_archive/_store/__init__.py +45 -0
  58. thread_archive/_store/_base.py +173 -0
  59. thread_archive/_store/_defaults.py +57 -0
  60. thread_archive/_store/_types.py +38 -0
  61. thread_archive/_store/models.py +370 -0
  62. thread_archive/_store/schema.py +84 -0
  63. thread_archive/_thread_import/__init__.py +40 -0
  64. thread_archive/_thread_import/api.py +78 -0
  65. thread_archive/_thread_import/event_builder.py +810 -0
  66. thread_archive/_thread_import/exporters/__init__.py +9 -0
  67. thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
  68. thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
  69. thread_archive/_thread_import/exporters/cursor.py +582 -0
  70. thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
  71. thread_archive/_thread_import/parsers/__init__.py +191 -0
  72. thread_archive/_thread_import/parsers/base.py +698 -0
  73. thread_archive/_thread_import/parsers/chatgpt.py +722 -0
  74. thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
  75. thread_archive/_thread_import/parsers/claude.py +443 -0
  76. thread_archive/_thread_import/parsers/claude_code.py +1035 -0
  77. thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
  78. thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
  79. thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
  80. thread_archive/_thread_import/parsers/config/__init__.py +26 -0
  81. thread_archive/_thread_import/parsers/config/base.py +220 -0
  82. thread_archive/_thread_import/parsers/cursor.py +538 -0
  83. thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
  84. thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
  85. thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
  86. thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
  87. thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
  88. thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
  89. thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
  90. thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
  91. thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
  92. thread_archive/_thread_import/parsers/types/__init__.py +56 -0
  93. thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
  94. thread_archive/_thread_import/parsers/types/claude.py +120 -0
  95. thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
  96. thread_archive/_thread_import/parsers/types/cursor.py +109 -0
  97. thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
  98. thread_archive/_thread_import/parsers/validators/base.py +168 -0
  99. thread_archive/_thread_import/parsers/validators/content.py +80 -0
  100. thread_archive/_thread_import/parsers/validators/referential.py +57 -0
  101. thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
  102. thread_archive/_thread_import/parsers/validators/types.py +87 -0
  103. thread_archive/_thread_import/schemas/__init__.py +231 -0
  104. thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
  105. thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
  106. thread_archive/_thread_import/schemas/claude/v1.json +188 -0
  107. thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
  108. thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
  109. thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
  110. thread_archive/_thread_import/timestamps.py +57 -0
  111. thread_archive/_thread_import/tool_names.py +48 -0
  112. thread_archive/_truth/__init__.py +40 -0
  113. thread_archive/_truth/jsonl_log.py +2340 -0
  114. thread_archive/_truth/repair.py +322 -0
  115. thread_archive/_watcher/__init__.py +57 -0
  116. thread_archive/_watcher/base.py +65 -0
  117. thread_archive/_watcher/daemon.py +289 -0
  118. thread_archive/_watcher/export_drop.py +203 -0
  119. thread_archive/_watcher/exthost.py +316 -0
  120. thread_archive/_watcher/lazy.py +117 -0
  121. thread_archive/_watcher/sources.py +616 -0
  122. thread_archive/_web/__init__.py +15 -0
  123. thread_archive/_web/server.py +299 -0
  124. thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
  125. thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
  126. thread_archive/_web/static/index.html +13 -0
  127. thread_archive/cli.py +746 -0
  128. thread_archive-0.0.2.dist-info/METADATA +296 -0
  129. thread_archive-0.0.2.dist-info/RECORD +132 -0
  130. thread_archive-0.0.2.dist-info/WHEEL +4 -0
  131. thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
  132. thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,143 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "cursor_export_v1",
4
+ "title": "Cursor Export v1",
5
+ "description": "Cursor IDE chat export format (from cursor_export tool)",
6
+ "type": "object",
7
+ "properties": {
8
+ "provider": {
9
+ "type": "string",
10
+ "const": "cursor"
11
+ },
12
+ "export_version": {
13
+ "type": "string"
14
+ },
15
+ "export_date": {
16
+ "type": "string",
17
+ "description": "ISO timestamp"
18
+ },
19
+ "conversations": {
20
+ "type": "array",
21
+ "items": {
22
+ "$ref": "#/definitions/conversation"
23
+ }
24
+ }
25
+ },
26
+ "definitions": {
27
+ "conversation": {
28
+ "type": "object",
29
+ "required": ["id"],
30
+ "properties": {
31
+ "id": {
32
+ "type": "string",
33
+ "description": "Conversation ID"
34
+ },
35
+ "title": {
36
+ "type": ["string", "null"],
37
+ "description": "Chat title"
38
+ },
39
+ "workspace_hash": {
40
+ "type": "string",
41
+ "description": "Hash identifying the workspace"
42
+ },
43
+ "workspace_name": {
44
+ "type": ["string", "null"],
45
+ "description": "Human-readable workspace name"
46
+ },
47
+ "workspace_uri": {
48
+ "type": ["string", "null"],
49
+ "description": "Path to the workspace"
50
+ },
51
+ "messages": {
52
+ "type": "array",
53
+ "items": {
54
+ "$ref": "#/definitions/message"
55
+ }
56
+ },
57
+ "bubbles": {
58
+ "type": "array",
59
+ "items": {
60
+ "$ref": "#/definitions/bubble"
61
+ },
62
+ "description": "Alternative message format (raw Cursor format)"
63
+ }
64
+ }
65
+ },
66
+ "message": {
67
+ "type": "object",
68
+ "properties": {
69
+ "id": {
70
+ "type": "string",
71
+ "description": "Message ID"
72
+ },
73
+ "role": {
74
+ "type": "string",
75
+ "enum": ["user", "assistant", "system", "tool"],
76
+ "description": "Message author role"
77
+ },
78
+ "content": {
79
+ "type": ["string", "null"],
80
+ "description": "Message text content"
81
+ },
82
+ "content_blocks": {
83
+ "type": "array",
84
+ "description": "Pre-parsed content blocks"
85
+ },
86
+ "created_at": {
87
+ "type": ["string", "null"],
88
+ "description": "ISO timestamp"
89
+ },
90
+ "tool_calls": {
91
+ "type": "array",
92
+ "description": "Tool/function calls made"
93
+ },
94
+ "tool_results": {
95
+ "type": "array",
96
+ "description": "Results from tool calls"
97
+ },
98
+ "model": {
99
+ "type": ["string", "null"],
100
+ "description": "Model used for response"
101
+ }
102
+ }
103
+ },
104
+ "bubble": {
105
+ "type": "object",
106
+ "description": "Raw Cursor bubble format",
107
+ "properties": {
108
+ "type": {
109
+ "type": "string",
110
+ "enum": ["user", "ai"],
111
+ "description": "Bubble type"
112
+ },
113
+ "text": {
114
+ "type": ["string", "null"],
115
+ "description": "Message text"
116
+ },
117
+ "rawText": {
118
+ "type": ["string", "null"],
119
+ "description": "Raw text before processing"
120
+ },
121
+ "codeBlocks": {
122
+ "type": "array",
123
+ "description": "Code blocks in the message"
124
+ },
125
+ "selections": {
126
+ "type": "array",
127
+ "description": "Code selections referenced"
128
+ },
129
+ "messageId": {
130
+ "type": ["string", "integer"],
131
+ "description": "Message identifier"
132
+ },
133
+ "relevantFiles": {
134
+ "type": "array",
135
+ "description": "Files relevant to this message"
136
+ },
137
+ "capabilityStatuses": {
138
+ "type": "array"
139
+ }
140
+ }
141
+ }
142
+ }
143
+ }
@@ -0,0 +1,200 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "cursor_export_v2",
4
+ "title": "Cursor Export v2",
5
+ "description": "Cursor IDE chat export format with toolFormerData (newer Cursor versions)",
6
+ "type": "object",
7
+ "properties": {
8
+ "provider": {
9
+ "type": "string",
10
+ "const": "cursor"
11
+ },
12
+ "export_version": {
13
+ "type": "string"
14
+ },
15
+ "export_date": {
16
+ "type": "string",
17
+ "description": "ISO timestamp"
18
+ },
19
+ "conversations": {
20
+ "type": "array",
21
+ "items": {
22
+ "$ref": "#/definitions/conversation"
23
+ }
24
+ }
25
+ },
26
+ "definitions": {
27
+ "conversation": {
28
+ "type": "object",
29
+ "required": ["id"],
30
+ "properties": {
31
+ "id": {
32
+ "type": "string",
33
+ "description": "Conversation ID"
34
+ },
35
+ "title": {
36
+ "type": ["string", "null"],
37
+ "description": "Chat title"
38
+ },
39
+ "workspace_hash": {
40
+ "type": "string",
41
+ "description": "Hash identifying the workspace"
42
+ },
43
+ "workspace_name": {
44
+ "type": ["string", "null"],
45
+ "description": "Human-readable workspace name"
46
+ },
47
+ "workspace_uri": {
48
+ "type": ["string", "null"],
49
+ "description": "Path to the workspace"
50
+ },
51
+ "messages": {
52
+ "type": "array",
53
+ "items": {
54
+ "$ref": "#/definitions/message"
55
+ }
56
+ },
57
+ "bubbles": {
58
+ "type": "array",
59
+ "items": {
60
+ "$ref": "#/definitions/bubble"
61
+ },
62
+ "description": "Alternative message format (raw Cursor format)"
63
+ }
64
+ }
65
+ },
66
+ "message": {
67
+ "type": "object",
68
+ "properties": {
69
+ "id": {
70
+ "type": "string",
71
+ "description": "Message ID"
72
+ },
73
+ "role": {
74
+ "type": "string",
75
+ "enum": ["user", "assistant", "system", "tool"],
76
+ "description": "Message author role"
77
+ },
78
+ "content": {
79
+ "type": ["string", "null"],
80
+ "description": "Message text content"
81
+ },
82
+ "content_blocks": {
83
+ "type": "array",
84
+ "description": "Pre-parsed content blocks"
85
+ },
86
+ "created_at": {
87
+ "type": ["string", "null"],
88
+ "description": "ISO timestamp"
89
+ },
90
+ "tool_calls": {
91
+ "type": "array",
92
+ "description": "Tool/function calls made"
93
+ },
94
+ "tool_results": {
95
+ "type": "array",
96
+ "description": "Results from tool calls"
97
+ },
98
+ "model": {
99
+ "type": ["string", "null"],
100
+ "description": "Model used for response"
101
+ },
102
+ "tool_call": {
103
+ "$ref": "#/definitions/toolCall",
104
+ "description": "v2: Structured tool call from toolFormerData"
105
+ },
106
+ "thinking": {
107
+ "$ref": "#/definitions/thinking",
108
+ "description": "v2: Thinking/reasoning content"
109
+ }
110
+ }
111
+ },
112
+ "bubble": {
113
+ "type": "object",
114
+ "description": "Raw Cursor bubble format with v2 toolFormerData",
115
+ "properties": {
116
+ "type": {
117
+ "type": "string",
118
+ "enum": ["user", "ai"],
119
+ "description": "Bubble type"
120
+ },
121
+ "text": {
122
+ "type": ["string", "null"],
123
+ "description": "Message text"
124
+ },
125
+ "rawText": {
126
+ "type": ["string", "null"],
127
+ "description": "Raw text before processing"
128
+ },
129
+ "codeBlocks": {
130
+ "type": "array",
131
+ "description": "Code blocks in the message"
132
+ },
133
+ "selections": {
134
+ "type": "array",
135
+ "description": "Code selections referenced"
136
+ },
137
+ "messageId": {
138
+ "type": ["string", "integer"],
139
+ "description": "Message identifier"
140
+ },
141
+ "relevantFiles": {
142
+ "type": "array",
143
+ "description": "Files relevant to this message"
144
+ },
145
+ "capabilityStatuses": {
146
+ "type": "array"
147
+ },
148
+ "toolFormerData": {
149
+ "$ref": "#/definitions/toolFormerData",
150
+ "description": "v2: Tool calling data from newer Cursor versions"
151
+ }
152
+ }
153
+ },
154
+ "toolFormerData": {
155
+ "type": "object",
156
+ "description": "Tool calling data structure (v2 indicator)",
157
+ "properties": {
158
+ "toolCalls": {
159
+ "type": "array",
160
+ "items": {
161
+ "$ref": "#/definitions/toolCall"
162
+ }
163
+ },
164
+ "toolResults": {
165
+ "type": "array"
166
+ }
167
+ }
168
+ },
169
+ "toolCall": {
170
+ "type": "object",
171
+ "properties": {
172
+ "name": {
173
+ "type": "string",
174
+ "description": "Tool name"
175
+ },
176
+ "arguments": {
177
+ "type": ["string", "object"],
178
+ "description": "Tool arguments (JSON string or object)"
179
+ },
180
+ "id": {
181
+ "type": "string",
182
+ "description": "Tool call ID"
183
+ }
184
+ }
185
+ },
186
+ "thinking": {
187
+ "type": "object",
188
+ "description": "Thinking/reasoning block",
189
+ "properties": {
190
+ "text": {
191
+ "type": "string"
192
+ },
193
+ "thinking_duration_ms": {
194
+ "type": "number",
195
+ "description": "v2 indicator: duration of thinking"
196
+ }
197
+ }
198
+ }
199
+ }
200
+ }
@@ -0,0 +1,57 @@
1
+ """Unified timestamp parsing for thread import.
2
+
3
+ Handles ISO 8601 strings (with or without Z suffix), unix epoch
4
+ (seconds or milliseconds), and None/empty values.
5
+ """
6
+
7
+ from datetime import datetime, timezone
8
+ from typing import Optional, Union
9
+
10
+
11
+ def parse_timestamp(ts: Optional[Union[str, int, float]], *, default: Optional[datetime] = None) -> Optional[datetime]:
12
+ """Parse a timestamp value into a datetime.
13
+
14
+ Args:
15
+ ts: ISO string, unix epoch (seconds or milliseconds), or None
16
+ default: fallback value when ts is None/empty/unparseable.
17
+ If not provided, returns None on failure.
18
+
19
+ Returns:
20
+ Parsed datetime, or default if parsing fails.
21
+ """
22
+ if ts is None or ts == "":
23
+ return default
24
+
25
+ # ISO string
26
+ if isinstance(ts, str):
27
+ try:
28
+ if ts.endswith("Z"):
29
+ ts = ts[:-1] + "+00:00"
30
+ dt = datetime.fromisoformat(ts)
31
+ except (ValueError, TypeError):
32
+ return default
33
+ # An offset-less ISO string parses to a naive datetime; treat it as UTC
34
+ # so callers never mix naive and aware datetimes (comparing the two
35
+ # raises TypeError in the event-ordering path). Explicit offsets are kept.
36
+ if dt.tzinfo is None:
37
+ dt = dt.replace(tzinfo=timezone.utc)
38
+ return dt
39
+
40
+ # Unix timestamp (seconds or milliseconds). Epoch is an absolute instant —
41
+ # build an aware UTC datetime, not a naive-local one (the host's local zone
42
+ # would silently skew the time by the UTC offset).
43
+ if isinstance(ts, (int, float)):
44
+ try:
45
+ if ts > 1_000_000_000_000:
46
+ ts = ts / 1000
47
+ return datetime.fromtimestamp(ts, tz=timezone.utc)
48
+ except (ValueError, OSError):
49
+ return default
50
+
51
+ return default
52
+
53
+
54
+ def parse_timestamp_iso(ts: Optional[Union[str, int, float]]) -> Optional[str]:
55
+ """Parse a timestamp and return as ISO string, or None."""
56
+ dt = parse_timestamp(ts)
57
+ return dt.isoformat() if dt else None
@@ -0,0 +1,48 @@
1
+ """Canonical tool name mapping from provider-specific names to Thread's standard names.
2
+
3
+ Thread uses Claude Code's tool names as the canonical standard (Edit, Write, Read, Bash, etc.)
4
+ since those are the de facto standard in the database. Provider-specific names (edit_file_v2,
5
+ run_terminal_cmd, etc.) are mapped to these canonical names during import.
6
+
7
+ The original provider name is preserved as `provider_tool_name` on content blocks and events.
8
+ """
9
+
10
+ # Provider tool name → Thread canonical name
11
+ TOOL_NAME_MAP: dict[str, str] = {
12
+ # Cursor file tools
13
+ "edit_file_v2": "Edit",
14
+ "edit_file": "Edit",
15
+ "write_file": "Write",
16
+ "read_file": "Read",
17
+ "read_file_v2": "Read",
18
+ # Cursor terminal tools
19
+ "run_terminal_cmd": "Bash",
20
+ "run_terminal_command_v2": "Bash",
21
+ # Cursor search/navigation tools
22
+ "list_dir": "Glob",
23
+ "list_dir_v2": "Glob",
24
+ "search_files": "Grep",
25
+ "codebase_search": "Grep",
26
+ "grep_search": "Grep",
27
+ }
28
+
29
+ # Canonical tool names that operate on files — normalized events MUST have input.file_path
30
+ FILE_TOOLS: frozenset[str] = frozenset({"Edit", "Write", "Read"})
31
+
32
+ # Provider-specific field name that holds the file path in the tool's input dict.
33
+ # None means the input doesn't contain the path at all (must recover from code_blocks).
34
+ PROVIDER_PATH_FIELDS: dict[str, str | None] = {
35
+ "read_file_v2": "path",
36
+ "read_file": "path",
37
+ "edit_file": None,
38
+ "edit_file_v2": None,
39
+ "write_file": "filePath",
40
+ }
41
+
42
+
43
+ def normalize_tool_name(name: str) -> str:
44
+ """Map a provider tool name to Thread's canonical name.
45
+
46
+ Returns the original name unchanged if no mapping exists.
47
+ """
48
+ return TOOL_NAME_MAP.get(name, name)
@@ -0,0 +1,40 @@
1
+ """JSONL truth-log: durable source of truth, with reindex as the recovery primitive.
2
+
3
+ JSONL is truth; ``index.db`` is a rebuildable projection. See :mod:`.jsonl_log`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .jsonl_log import (
9
+ append_event_row,
10
+ append_kg_event,
11
+ checkpoint,
12
+ log_dir,
13
+ rebuild_truth_from_store,
14
+ record_thread,
15
+ reindex,
16
+ reset_handles,
17
+ scan_truth_counts,
18
+ shared_ingest_lock,
19
+ try_shared_ingest_lock,
20
+ unstage_thread,
21
+ write_events,
22
+ )
23
+ from .repair import repair_truth
24
+
25
+ __all__ = [
26
+ "write_events",
27
+ "append_event_row",
28
+ "append_kg_event",
29
+ "record_thread",
30
+ "unstage_thread",
31
+ "checkpoint",
32
+ "reindex",
33
+ "rebuild_truth_from_store",
34
+ "log_dir",
35
+ "repair_truth",
36
+ "reset_handles",
37
+ "scan_truth_counts",
38
+ "shared_ingest_lock",
39
+ "try_shared_ingest_lock",
40
+ ]