@giovannijecha/jecode 0.5.0 → 0.7.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.
@@ -9,7 +9,7 @@ import * as path from "node:path";
9
9
  import { atomicWrite } from "../atomic.js";
10
10
  import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
11
11
  import { userDataPath } from "../user-data.js";
12
- import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, } from "./codec.js";
12
+ import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_SCHEMA, } from "./codec.js";
13
13
  import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
14
14
  const DIRECTORY_MODE = 0o700;
15
15
  const FILE_MODE = 0o600;
@@ -104,7 +104,7 @@ export class DurableSessionStore {
104
104
  throw new Error("session checkpoint cannot be recovered safely");
105
105
  }
106
106
  head = {
107
- version: 1,
107
+ version: SESSION_SCHEMA,
108
108
  sequence: candidate.sequence,
109
109
  nodeId: candidate.node.id,
110
110
  parentId: candidate.node.parentId,
@@ -128,14 +128,14 @@ export class DurableSessionStore {
128
128
  const temporary = path.join(this.#bucket, `.${id}.${randomUUID()}.tmp`);
129
129
  const target = this.#sessionDirectory(id);
130
130
  const meta = {
131
- version: 1,
131
+ version: SESSION_SCHEMA,
132
132
  id,
133
133
  workspaceRoot: this.workspaceRoot,
134
134
  workspaceDigest: this.workspaceDigest,
135
135
  createdAt: now,
136
136
  };
137
137
  const head = {
138
- version: 1,
138
+ version: SESSION_SCHEMA,
139
139
  sequence: conversation.nodes.length,
140
140
  nodeId: active.id,
141
141
  parentId: active.parentId,
@@ -187,7 +187,7 @@ export class DurableSessionStore {
187
187
  assertSharedNodes(previous.conversation, conversation, replacesHead ? active.id : undefined);
188
188
  const now = new Date().toISOString();
189
189
  const head = {
190
- version: 1,
190
+ version: SESSION_SCHEMA,
191
191
  sequence: previous.head.sequence + 1,
192
192
  nodeId: active.id,
193
193
  parentId: active.parentId,
@@ -1,6 +1,7 @@
1
1
  // The persistent settings hub. Provider access and model selection reuse the
2
2
  // same command flows exposed directly through /providers and /models.
3
3
  import { saveCommandSettings } from "./command-settings.js";
4
+ import { MAX_COMPACTION_PERCENT, MIN_COMPACTION_PERCENT } from "./context/policy.js";
4
5
  import { modelsCommand } from "./model-command.js";
5
6
  import { providerFailure } from "./provider-errors.js";
6
7
  import { providerLabel } from "./provider-label.js";
@@ -43,6 +44,9 @@ export async function settingsCommand(session, host) {
43
44
  case "maxSteps":
44
45
  await numberSetting(session, host, "maxSteps", "max tool steps");
45
46
  break;
47
+ case "compactionPercent":
48
+ await compactionSetting(session, host);
49
+ break;
46
50
  case "reducedMotion":
47
51
  await motionSetting(session, host);
48
52
  break;
@@ -53,9 +57,11 @@ export async function settingsCommand(session, host) {
53
57
  }
54
58
  }
55
59
  export function settingsPicker(values, index = 0) {
60
+ const items = settingsItems(values);
56
61
  return {
57
62
  title: [],
58
- options: settingsItems(values).map((item) => item.option),
63
+ options: items.map((item) => item.option),
64
+ visible: items.length,
59
65
  index,
60
66
  };
61
67
  }
@@ -76,13 +82,17 @@ function settingsItems(values) {
76
82
  option: { label: "max output tokens", value: String(values.maxTokens) },
77
83
  }]),
78
84
  { action: "maxSteps", option: { label: "max tool steps", value: String(values.maxSteps) } },
85
+ {
86
+ action: "compactionPercent",
87
+ option: { label: "context compaction", value: `${values.compactionPercent}%` },
88
+ },
79
89
  {
80
90
  action: "reducedMotion",
81
91
  option: { label: "reduced motion", value: values.reducedMotion ? "on" : "off" },
82
92
  },
83
93
  {
84
94
  action: "providers",
85
- option: { label: "providers", hint: "manage access and connections" },
95
+ option: { label: "providers", hint: "manage connections" },
86
96
  },
87
97
  ];
88
98
  }
@@ -93,6 +103,7 @@ function settingsValues(session) {
93
103
  effort: session.config.effort,
94
104
  ...(session.provider.id === "openai-codex" ? {} : { maxTokens: session.config.maxTokens }),
95
105
  maxSteps: session.config.maxSteps,
106
+ compactionPercent: session.config.compactionPercent,
96
107
  reducedMotion: session.config.reducedMotion,
97
108
  };
98
109
  }
@@ -185,6 +196,35 @@ async function numberSetting(session, host, name, label) {
185
196
  return;
186
197
  session.config[name] = value;
187
198
  }
199
+ async function compactionSetting(session, host) {
200
+ if (host.type === undefined)
201
+ return;
202
+ const label = "context compaction";
203
+ const field = {
204
+ title: heading(label, `${MIN_COMPACTION_PERCENT}-${MAX_COMPACTION_PERCENT} percent`, session.palette),
205
+ right: "enter save · esc back",
206
+ editor: of(String(session.config.compactionPercent)),
207
+ secret: false,
208
+ note: "Compacts when model context reaches this percentage.",
209
+ };
210
+ const text = await host.type(field);
211
+ if (text === undefined)
212
+ return;
213
+ const value = Number(text);
214
+ if (!Number.isSafeInteger(value) ||
215
+ value < MIN_COMPACTION_PERCENT ||
216
+ value > MAX_COMPACTION_PERCENT) {
217
+ host.emit({
218
+ kind: "notice",
219
+ text: `${label} must be from ${MIN_COMPACTION_PERCENT} to ${MAX_COMPACTION_PERCENT}`,
220
+ tone: "error",
221
+ });
222
+ return;
223
+ }
224
+ if (!(await saveCommandSettings(host, { compactionPercent: value })))
225
+ return;
226
+ session.config.compactionPercent = value;
227
+ }
188
228
  function chooser(host) {
189
229
  if (host.choose === undefined) {
190
230
  host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
package/dist/settings.js CHANGED
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { chmod, mkdir } from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import { atomicWrite } from "./atomic.js";
6
+ import { MAX_COMPACTION_PERCENT, MIN_COMPACTION_PERCENT } from "./context/policy.js";
6
7
  import { EFFORTS } from "./effort.js";
7
8
  import { providerNames } from "./providers/index.js";
8
9
  import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
@@ -59,6 +60,7 @@ function normalize(value) {
59
60
  const reducedMotion = typeof value["reducedMotion"] === "boolean" ? value["reducedMotion"] : undefined;
60
61
  const maxTokens = positiveInteger(value["maxTokens"]);
61
62
  const maxSteps = positiveInteger(value["maxSteps"]);
63
+ const compactionPercent = percentage(value["compactionPercent"]);
62
64
  return {
63
65
  ...(provider === undefined ? {} : { provider }),
64
66
  ...(models === undefined ? {} : { models }),
@@ -67,6 +69,7 @@ function normalize(value) {
67
69
  ...(reducedMotion === undefined ? {} : { reducedMotion }),
68
70
  ...(maxTokens === undefined ? {} : { maxTokens }),
69
71
  ...(maxSteps === undefined ? {} : { maxSteps }),
72
+ ...(compactionPercent === undefined ? {} : { compactionPercent }),
70
73
  };
71
74
  }
72
75
  function modelsOf(value, providers) {
@@ -91,6 +94,12 @@ function endpoint(value) {
91
94
  function positiveInteger(value) {
92
95
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
93
96
  }
97
+ function percentage(value) {
98
+ return typeof value === "number" && Number.isSafeInteger(value) &&
99
+ value >= MIN_COMPACTION_PERCENT && value <= MAX_COMPACTION_PERCENT
100
+ ? value
101
+ : undefined;
102
+ }
94
103
  function record(value) {
95
104
  return typeof value === "object" && value !== null && !Array.isArray(value);
96
105
  }
@@ -0,0 +1,90 @@
1
+ // A compact, read-only projection of the durable conversation tree.
2
+ //
3
+ // Selecting a node changes only the in-memory path. The next real user turn
4
+ // is what persists a branch, so opening or cancelling this control plane can
5
+ // never create empty history.
6
+ import { usageFromHistory } from "./usage.js";
7
+ import { heading } from "./tui/picker.js";
8
+ export function timelinePicker(conversation, palette) {
9
+ const entries = timelineEntries(conversation);
10
+ const selectedId = conversation.latestCompleted()?.activeNodeId ?? 0;
11
+ const index = Math.max(0, entries.findIndex((entry) => entry.node.id === selectedId));
12
+ return Object.freeze({
13
+ picker: {
14
+ title: heading("timeline", "conversation tree", palette),
15
+ searchable: true,
16
+ query: "",
17
+ visible: 8,
18
+ options: entries.map((entry) => ({
19
+ label: `${entry.prefix}${preview(entry.node)}`,
20
+ hint: stamp(entry.node.createdAt),
21
+ ...(entry.node.id === selectedId ? { value: "active" } : {}),
22
+ })),
23
+ index,
24
+ },
25
+ nodeIds: Object.freeze(entries.map((entry) => entry.node.id)),
26
+ });
27
+ }
28
+ export async function selectTimeline(session, choose) {
29
+ const timeline = timelinePicker(session.conversation, session.palette);
30
+ if (timeline.nodeIds.length === 0)
31
+ return false;
32
+ const index = await choose(timeline.picker);
33
+ const nodeId = index === undefined ? undefined : timeline.nodeIds[index];
34
+ if (nodeId === undefined || nodeId === session.conversation.activeNodeId)
35
+ return false;
36
+ session.conversation = session.conversation.select(nodeId);
37
+ session.usage = usageFromHistory(session.conversation.history);
38
+ return true;
39
+ }
40
+ function timelineEntries(conversation) {
41
+ const completed = conversation.nodes.filter((node) => node.settlement === "completed");
42
+ const completedIds = new Set(completed.map((node) => node.id));
43
+ const children = new Map();
44
+ for (const node of completed) {
45
+ let parentId = node.parentId;
46
+ while (parentId !== 0 && !completedIds.has(parentId)) {
47
+ parentId = conversation.node(parentId)?.parentId ?? 0;
48
+ }
49
+ const siblings = children.get(parentId) ?? [];
50
+ siblings.push(node);
51
+ children.set(parentId, siblings);
52
+ }
53
+ const entries = [];
54
+ const visit = (parentId, lanes) => {
55
+ const siblings = children.get(parentId) ?? [];
56
+ for (let index = 0; index < siblings.length; index++) {
57
+ const node = siblings[index];
58
+ const forks = siblings.length > 1;
59
+ const last = index === siblings.length - 1;
60
+ const hidden = Math.max(0, lanes.length - 4);
61
+ const lanePrefix = `${hidden === 0 ? "" : "… "}${lanes.slice(hidden)
62
+ .map((closed) => closed ? " " : "│ ").join("")}`;
63
+ entries.push({
64
+ node,
65
+ prefix: `${lanePrefix}${forks ? (last ? "└─ " : "├─ ") : "• "}`,
66
+ });
67
+ visit(node.id, forks ? [...lanes, last] : lanes);
68
+ }
69
+ };
70
+ visit(0, []);
71
+ return entries;
72
+ }
73
+ function preview(node) {
74
+ for (const message of node.messages) {
75
+ if (message.role !== "user")
76
+ continue;
77
+ const text = message.content.find((block) => block.kind === "text")?.text
78
+ .replace(/\s+/gu, " ").trim();
79
+ if (text !== undefined && text !== "")
80
+ return text.slice(0, 160);
81
+ }
82
+ return "Untitled turn";
83
+ }
84
+ function stamp(value) {
85
+ const date = new Date(value);
86
+ if (!Number.isFinite(date.getTime()))
87
+ return value.replace("T", " ").slice(0, 16);
88
+ const two = (part) => String(part).padStart(2, "0");
89
+ return `${two(date.getHours())}:${two(date.getMinutes())}`;
90
+ }
@@ -13,5 +13,6 @@ export function appState() {
13
13
  draft: "",
14
14
  spin: 0,
15
15
  closeWhenIdle: false,
16
+ committedNodeId: 0,
16
17
  };
17
18
  }
@@ -1,9 +1,14 @@
1
1
  // Foreground command and model-turn workflows for the TUI shell.
2
2
  import { handleCommand } from "../commands.js";
3
3
  import { runTurn } from "../controller.js";
4
+ import { resolveContextPolicy } from "../context/capacity.js";
5
+ import { compactContext } from "../context/compactor.js";
6
+ import { compactSession } from "../context/manual.js";
7
+ import { isContextOverflow, shouldResolveContextPolicy } from "../context/policy.js";
4
8
  import { updateSettings } from "../settings.js";
5
9
  import { saveTranscript } from "../transcript-export.js";
6
- import { recordUsage } from "../usage.js";
10
+ import { recordAuxiliaryUsage, recordUsage } from "../usage.js";
11
+ import { selectTimeline } from "../timeline.js";
7
12
  import { answerAt } from "./approve.js";
8
13
  import { cancel as cancelOpen } from "./overlay.js";
9
14
  import { controllerOptions, turnFailure } from "./session-view.js";
@@ -11,6 +16,10 @@ import { transcribe } from "./turn.js";
11
16
  const WAITING = "Waiting";
12
17
  export function appWorkflows(options) {
13
18
  const { session, state, permissions, feedback } = options;
19
+ const choose = (picker) => new Promise((resolve) => {
20
+ state.open = { picker, settle: resolve };
21
+ options.render();
22
+ });
14
23
  async function command(text) {
15
24
  const activity = options.startActivity("command", `Running ${text.split(/\s+/)[0]}`);
16
25
  if (activity === undefined)
@@ -23,10 +32,7 @@ export function appWorkflows(options) {
23
32
  state.open = { help: true, settle: resolve };
24
33
  options.render();
25
34
  }),
26
- choose: (picker) => new Promise((resolve) => {
27
- state.open = { picker, settle: resolve };
28
- options.render();
29
- }),
35
+ choose,
30
36
  dismiss: () => {
31
37
  state.open = state.open === undefined ? undefined : cancelOpen(state.open);
32
38
  options.render();
@@ -48,6 +54,7 @@ export function appWorkflows(options) {
48
54
  state.follow = true;
49
55
  state.unseen = 0;
50
56
  state.lastMaxScroll = 0;
57
+ state.committedNodeId = 0;
51
58
  },
52
59
  permissions,
53
60
  exportTranscript: () => saveTranscript(options.transcriptRoot, state.blocks),
@@ -55,6 +62,27 @@ export function appWorkflows(options) {
55
62
  await updateSettings(patch);
56
63
  },
57
64
  refreshSettings: options.refreshSettings,
65
+ timeline: async () => {
66
+ const selected = await selectTimeline(session, choose);
67
+ if (!selected)
68
+ return "unchanged";
69
+ options.replaceTranscript();
70
+ return session.conversation.activeNodeId === state.committedNodeId
71
+ ? "unchanged"
72
+ : "selected";
73
+ },
74
+ compact: async () => {
75
+ if (session.conversation.activeNodeId !== state.committedNodeId) {
76
+ return "branch-pending";
77
+ }
78
+ return compactSession(session, {
79
+ signal: activity.control.signal,
80
+ onStatus: (status) => {
81
+ state.status = status ?? activity.label;
82
+ options.render();
83
+ },
84
+ });
85
+ },
58
86
  });
59
87
  if (outcome === "exit")
60
88
  state.closeWhenIdle = true;
@@ -81,12 +109,18 @@ export function appWorkflows(options) {
81
109
  return;
82
110
  const parentId = session.conversation.activeNodeId;
83
111
  const history = session.conversation.history;
112
+ const modelHistory = session.conversation.contextHistory;
84
113
  const historyStart = history.length;
85
114
  const blockStart = state.blocks.length;
86
115
  const createdAt = new Date().toISOString();
116
+ const prospectiveNodeId = session.conversation.nodes.length + 1;
87
117
  let nodeId;
118
+ let context;
119
+ let contextPolicy;
88
120
  options.emit({ kind: "user", text });
89
- history.push({ role: "user", content: [{ kind: "text", text }] });
121
+ const user = { role: "user", content: [{ kind: "text", text }] };
122
+ history.push(user);
123
+ modelHistory.push(structuredClone(user));
90
124
  const events = transcribe({
91
125
  emit: options.emit,
92
126
  render: options.render,
@@ -102,7 +136,7 @@ export function appWorkflows(options) {
102
136
  },
103
137
  usage: (usage) => recordUsage(session.usage, usage),
104
138
  });
105
- events.onCheckpoint = async (checkpoint, settlement) => {
139
+ const persist = async (checkpoint, settlement) => {
106
140
  const next = session.conversation.commit({
107
141
  ...(nodeId === undefined ? {} : { nodeId }),
108
142
  parentId,
@@ -114,14 +148,77 @@ export function appWorkflows(options) {
114
148
  },
115
149
  messages: checkpoint.slice(historyStart),
116
150
  blocks: state.blocks.slice(blockStart),
151
+ ...(context === undefined ? {} : { context }),
117
152
  }, settlement);
118
153
  await session.persistence?.checkpoint(next);
119
154
  session.conversation = next;
120
155
  nodeId = next.activeNodeId;
156
+ state.committedNodeId = next.activeNodeId;
157
+ };
158
+ const compact = async (checkpoint, projected, reason, error) => {
159
+ if (reason === "overflow" && (error === undefined || !isContextOverflow(error))) {
160
+ return undefined;
161
+ }
162
+ const force = reason === "overflow";
163
+ if (!shouldResolveContextPolicy(projected, session.usage.lastInputTokens, force)) {
164
+ return undefined;
165
+ }
166
+ if (contextPolicy === undefined) {
167
+ state.status = "Checking context";
168
+ options.render();
169
+ contextPolicy = resolveContextPolicy({
170
+ provider: session.provider,
171
+ model: session.model,
172
+ compactionPercent: session.config.compactionPercent,
173
+ signal: activity.control.signal,
174
+ onStatus: (status) => {
175
+ state.status = status;
176
+ options.render();
177
+ },
178
+ }).finally(() => {
179
+ state.status = WAITING;
180
+ options.render();
181
+ });
182
+ }
183
+ const result = await compactContext({
184
+ provider: session.provider,
185
+ model: session.model,
186
+ effort: session.config.effort,
187
+ context: projected,
188
+ turn: checkpoint.slice(historyStart),
189
+ nodeId: nodeId ?? prospectiveNodeId,
190
+ coveredMessages: context?.messageCount ?? 0,
191
+ lastInputTokens: session.usage.lastInputTokens,
192
+ signal: activity.control.signal,
193
+ force,
194
+ policy: await contextPolicy,
195
+ onBegin: () => {
196
+ state.status = "Compacting";
197
+ options.render();
198
+ },
199
+ onEnd: () => {
200
+ state.status = WAITING;
201
+ options.render();
202
+ },
203
+ });
204
+ if (result === undefined)
205
+ return undefined;
206
+ context = result.anchor;
207
+ if (result.usage !== undefined)
208
+ recordAuxiliaryUsage(session.usage, result.usage);
209
+ return result.messages;
210
+ };
211
+ events.onContext = compact;
212
+ events.onCheckpoint = async (checkpoint, settlement, projected) => {
213
+ await persist(checkpoint, settlement);
214
+ const compacted = await compact(checkpoint, projected, "budget");
215
+ if (compacted !== undefined)
216
+ await persist(checkpoint, settlement);
217
+ return compacted;
121
218
  };
122
219
  let finishReason;
123
220
  try {
124
- await runTurn(history, controllerOptions(session, permissions.availableTools()), events, activity.control.signal);
221
+ await runTurn(history, controllerOptions(session, permissions.availableTools()), events, activity.control.signal, modelHistory);
125
222
  }
126
223
  catch (error) {
127
224
  const interrupted = activity.control.signal.aborted;
package/dist/tui/app.js CHANGED
@@ -32,6 +32,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
32
32
  const workspace = await workspaceLabel(session.config.root);
33
33
  const state = appState();
34
34
  state.blocks.push(...session.conversation.transcript);
35
+ state.committedNodeId = session.conversation.activeNodeId;
35
36
  const permissions = sessionPermissions(session.tools, session.config.autoApprove);
36
37
  let closed;
37
38
  let frameTimer;
@@ -116,6 +117,16 @@ export async function runApp(session, transcriptRoot, environment = {}) {
116
117
  if (state.follow)
117
118
  state.unseen = 0;
118
119
  };
120
+ const replaceTranscript = () => {
121
+ state.blocks.splice(0, state.blocks.length, ...session.conversation.transcript);
122
+ state.scroll = 0;
123
+ state.follow = true;
124
+ state.unseen = 0;
125
+ state.lastMaxScroll = 0;
126
+ transcript.invalidate();
127
+ paint.invalidate();
128
+ render();
129
+ };
119
130
  function quit() {
120
131
  if (!live)
121
132
  return;
@@ -187,6 +198,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
187
198
  emit,
188
199
  commandNotice,
189
200
  render,
201
+ replaceTranscript,
190
202
  refreshSettings: () => {
191
203
  terminal.setReducedMotion(session.config.reducedMotion);
192
204
  paint.invalidate();
@@ -229,14 +241,9 @@ export async function runApp(session, transcriptRoot, environment = {}) {
229
241
  return;
230
242
  try {
231
243
  await launch.open(candidate.id);
232
- state.blocks.splice(0, state.blocks.length, ...session.conversation.transcript);
244
+ replaceTranscript();
245
+ state.committedNodeId = session.conversation.activeNodeId;
233
246
  state.past.length = 0;
234
- state.scroll = 0;
235
- state.follow = true;
236
- state.unseen = 0;
237
- state.lastMaxScroll = 0;
238
- transcript.invalidate();
239
- paint.invalidate();
240
247
  finishActivity(activity);
241
248
  return;
242
249
  }
@@ -5,11 +5,13 @@ export function renderNotice(block, width, pal) {
5
5
  warn: pal.ink.attention,
6
6
  error: pal.ink.removed,
7
7
  };
8
- const mark = block.tone === "error" ? "× " : block.tone === "warn" ? "! " : "· ";
8
+ const mark = block.tone === "error" ? "× " : block.tone === "warn" ? "! " : undefined;
9
9
  return [
10
10
  "",
11
11
  ...wrap(block.text, Math.max(1, width - 3)).map((line, index) => row(width, [
12
- { text: index === 0 ? mark : " ", fg: fg[block.tone], bold: index === 0 },
12
+ ...(mark === undefined
13
+ ? []
14
+ : [{ text: index === 0 ? mark : " ", fg: fg[block.tone], bold: index === 0 }]),
13
15
  { text: line, fg: fg[block.tone] },
14
16
  ], [], undefined, 1)),
15
17
  ];
package/dist/usage.js CHANGED
@@ -13,6 +13,14 @@ export function emptyUsage() {
13
13
  export function recordUsage(total, next) {
14
14
  total.requests += 1;
15
15
  total.lastInputTokens = next.inputTokens;
16
+ addUsage(total, next);
17
+ }
18
+ /** Account for an internal request without replacing the main context signal. */
19
+ export function recordAuxiliaryUsage(total, next) {
20
+ total.requests += 1;
21
+ addUsage(total, next);
22
+ }
23
+ function addUsage(total, next) {
16
24
  total.inputTokens += next.inputTokens;
17
25
  total.outputTokens += next.outputTokens;
18
26
  total.cachedInputTokens += next.cachedInputTokens;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giovannijecha/jecode",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "An owned coding agent with zero external runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {