feat(core): add v2 manual compaction (#34336)
This commit is contained in:
parent
53b93b6991
commit
f7034a35a8
13 changed files with 438 additions and 80 deletions
|
|
@ -26,6 +26,7 @@ import { Reference } from "./reference"
|
|||
import { ReferenceGuidance } from "./reference/guidance"
|
||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SessionCompaction } from "./session/compaction"
|
||||
import { SessionTodo } from "./session/todo"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { SkillGuidance } from "./skill/guidance"
|
||||
|
|
@ -74,6 +75,7 @@ export const locationServices = LayerNode.group([
|
|||
ReadToolFileSystem.node,
|
||||
BuiltInTools.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionCompaction.node,
|
||||
Snapshot.node,
|
||||
SessionRunnerLLM.node,
|
||||
])
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { MessageDecodeError } from "./session/error"
|
|||
import { SessionEvent } from "./session/event"
|
||||
import { SessionInput } from "./session/input"
|
||||
import { Snapshot } from "./snapshot"
|
||||
import { SessionCompaction } from "./session/compaction"
|
||||
import { SessionRevert } from "./session/revert"
|
||||
import { Revert } from "@opencode-ai/schema/revert"
|
||||
import { FSUtil } from "./fs-util"
|
||||
|
|
@ -87,7 +88,6 @@ type CreateInput = CreateBaseInput &
|
|||
|
||||
type CompactInput = {
|
||||
sessionID: SessionSchema.ID
|
||||
prompt?: Prompt
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
|
||||
|
|
@ -113,7 +113,7 @@ export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.Bus
|
|||
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
|
||||
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError
|
||||
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError | BusyError
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
|
||||
|
|
@ -169,7 +169,9 @@ export interface Interface {
|
|||
skill: string
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, OperationUnavailableError>
|
||||
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly compact: (
|
||||
input: CompactInput,
|
||||
) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError>
|
||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||
|
|
@ -437,8 +439,19 @@ export const layer = Layer.effect(
|
|||
})
|
||||
}),
|
||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "compact" })
|
||||
const session = yield* result.get(input.sessionID)
|
||||
// TODO: admit manual compaction as durable pending work, like prompt input, instead of rejecting active sessions.
|
||||
if ((yield* execution.active).has(input.sessionID)) return yield* new BusyError({ sessionID: input.sessionID })
|
||||
const context = yield* store.context(input.sessionID)
|
||||
const compacted = yield* Effect.gen(function* () {
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
return yield* compaction.compactManual({ session, messages: context })
|
||||
}).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
if (!compacted) return yield* new OperationUnavailableError({ operation: "compact" })
|
||||
return undefined
|
||||
}),
|
||||
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
export * as SessionCompaction from "./compaction"
|
||||
|
||||
import { LLM, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
|
||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||
import type { Config } from "../config"
|
||||
import { Config as ConfigV2 } from "../config"
|
||||
import type { EventV2 } from "../event"
|
||||
import { EventV2 as EventV2Service } from "../event"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { llmClient } from "../effect/app-node-platform"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { Token } from "../util/token"
|
||||
|
||||
|
|
@ -50,11 +55,6 @@ Rules:
|
|||
- Preserve exact file paths, commands, error strings, and identifiers when known.
|
||||
- Do not mention the summary process or that context was compacted.`
|
||||
|
||||
type Entry = {
|
||||
readonly seq: number
|
||||
readonly message: SessionMessage.Message
|
||||
}
|
||||
|
||||
type Settings = {
|
||||
readonly auto: boolean
|
||||
readonly buffer: number
|
||||
|
|
@ -69,13 +69,31 @@ type Dependencies = {
|
|||
readonly config: readonly Config.Entry[]
|
||||
}
|
||||
|
||||
type Input = {
|
||||
export type AutoInput = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly entries: readonly Entry[]
|
||||
readonly model: Model
|
||||
readonly messages: readonly SessionMessage.Message[]
|
||||
readonly request: LLMRequest
|
||||
}
|
||||
|
||||
type CompactInput = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messages: readonly SessionMessage.Message[]
|
||||
readonly model: Model
|
||||
}
|
||||
|
||||
export type ManualInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Message[]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly compactIfNeeded: (input: AutoInput) => Effect.Effect<boolean>
|
||||
readonly compactAfterOverflow: (input: AutoInput) => Effect.Effect<boolean>
|
||||
readonly compactManual: (input: ManualInput) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionCompaction") {}
|
||||
|
||||
const estimate = (value: unknown) => Token.estimate(JSON.stringify(value))
|
||||
|
||||
const truncate = (value: string) =>
|
||||
|
|
@ -131,14 +149,14 @@ const settings = (documents: readonly Config.Entry[]) => {
|
|||
}
|
||||
|
||||
const select = (
|
||||
entries: readonly Entry[],
|
||||
messages: readonly SessionMessage.Message[],
|
||||
tokens: number,
|
||||
): { readonly head: string; readonly recent: string } | undefined => {
|
||||
const conversation = entries
|
||||
.filter((entry) => entry.message.type !== "compaction")
|
||||
.map((entry) => serialize(entry.message))
|
||||
const conversation = messages
|
||||
.filter((message) => message.type !== "compaction")
|
||||
.map(serialize)
|
||||
.filter(Boolean)
|
||||
if (conversation.length === 0) return
|
||||
if (conversation.length === 0) return undefined
|
||||
let total = 0
|
||||
let split = conversation.length
|
||||
let splitPrefix = ""
|
||||
|
|
@ -172,19 +190,21 @@ export const buildPrompt = (input: { readonly previousSummary?: string; readonly
|
|||
...input.context,
|
||||
].join("\n\n")
|
||||
|
||||
export const make = (dependencies: Dependencies) => {
|
||||
const make = (dependencies: Dependencies) => {
|
||||
const config = settings(dependencies.config)
|
||||
const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: Input) {
|
||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly model: Model
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly previousSummary?: string
|
||||
readonly context: readonly string[]
|
||||
readonly recent: string
|
||||
readonly output?: number
|
||||
}) {
|
||||
const context = input.model.route.defaults.limits?.context
|
||||
if (context === undefined || context <= 0) return false
|
||||
const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
|
||||
const selected = select(input.entries, config.tokens)
|
||||
const previousSummary = input.entries.find((entry) => entry.message.type === "compaction")?.message
|
||||
if (!selected || (selected.head.length === 0 && previousSummary?.type !== "compaction")) return false
|
||||
const summaryPrompt = buildPrompt({
|
||||
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
|
||||
context: [previousSummary?.type === "compaction" ? previousSummary.recent : "", selected.head].filter(Boolean),
|
||||
})
|
||||
const output = input.output ?? input.model.route.defaults.limits?.output ?? 0
|
||||
const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context })
|
||||
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
|
||||
if (Token.estimate(summaryPrompt) > context - summaryOutput) return false
|
||||
const messageID = SessionMessage.ID.create()
|
||||
|
|
@ -192,7 +212,7 @@ export const make = (dependencies: Dependencies) => {
|
|||
sessionID: input.sessionID,
|
||||
messageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
reason: "auto",
|
||||
reason: input.reason,
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
|
|
@ -221,17 +241,58 @@ export const make = (dependencies: Dependencies) => {
|
|||
sessionID: input.sessionID,
|
||||
messageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
reason: "auto",
|
||||
reason: input.reason,
|
||||
text: summary,
|
||||
recent: selected.recent,
|
||||
recent: input.recent,
|
||||
})
|
||||
return true
|
||||
})
|
||||
const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: Input) {
|
||||
if (!config.auto) return false
|
||||
const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: AutoInput) {
|
||||
return yield* compactSelected({
|
||||
sessionID: input.sessionID,
|
||||
messages: input.messages,
|
||||
model: input.request.model,
|
||||
reason: "auto",
|
||||
force: false,
|
||||
output: input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0,
|
||||
})
|
||||
})
|
||||
const compactSelected = Effect.fn("SessionCompaction.compactSelected")(function* (
|
||||
input: CompactInput & {
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly force: boolean
|
||||
readonly output?: number
|
||||
},
|
||||
) {
|
||||
const context = input.model.route.defaults.limits?.context
|
||||
if (context === undefined || context <= 0) return false
|
||||
const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
|
||||
const selected = select(input.messages, config.tokens)
|
||||
if (!selected) return false
|
||||
const previousSummary = input.messages.find((message) => message.type === "compaction")
|
||||
const hasHead = selected.head.length > 0
|
||||
if (!hasHead && previousSummary?.type !== "compaction" && !input.force) return false
|
||||
const forcedShortContext = input.force && !hasHead
|
||||
const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : ""
|
||||
return yield* compact({
|
||||
sessionID: input.sessionID,
|
||||
model: input.model,
|
||||
reason: input.reason,
|
||||
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
|
||||
context: (forcedShortContext ? [previousRecent, selected.recent] : [previousRecent, selected.head]).filter(
|
||||
Boolean,
|
||||
),
|
||||
recent: forcedShortContext ? "" : selected.recent,
|
||||
output: input.output,
|
||||
})
|
||||
})
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) {
|
||||
return yield* compactSelected({ ...input, reason: "manual", force: true })
|
||||
})
|
||||
const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: AutoInput) {
|
||||
if (!config.auto) return false
|
||||
const context = input.request.model.route.defaults.limits?.context
|
||||
if (context === undefined || context <= 0) return false
|
||||
const output = input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0
|
||||
if (
|
||||
estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <=
|
||||
context - Math.max(output, config.buffer)
|
||||
|
|
@ -242,5 +303,37 @@ export const make = (dependencies: Dependencies) => {
|
|||
return {
|
||||
compactIfNeeded,
|
||||
compactAfterOverflow,
|
||||
compactManual,
|
||||
}
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2Service.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const config = yield* ConfigV2.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const compaction = make({ events, llm, config: yield* config.entries() })
|
||||
|
||||
return Service.of({
|
||||
compactIfNeeded: compaction.compactIfNeeded,
|
||||
compactAfterOverflow: compaction.compactAfterOverflow,
|
||||
compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) {
|
||||
const model = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!model) return false
|
||||
return yield* compaction.compactManual({
|
||||
sessionID: input.session.id,
|
||||
messages: input.messages,
|
||||
model,
|
||||
})
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [EventV2Service.node, llmClient, ConfigV2.node, SessionRunnerModel.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
} from "@opencode-ai/llm"
|
||||
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { Database } from "../../database/database"
|
||||
import { EventV2 } from "../../event"
|
||||
import { Location } from "../../location"
|
||||
|
|
@ -103,10 +102,9 @@ export const layer = Layer.effect(
|
|||
const systemContext = yield* SystemContextRegistry.Service
|
||||
const skillGuidance = yield* SkillGuidance.Service
|
||||
const referenceGuidance = yield* ReferenceGuidance.Service
|
||||
const config = yield* Config.Service
|
||||
const snapshots = yield* Snapshot.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() })
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
|
||||
|
|
@ -211,7 +209,7 @@ export const layer = Layer.effect(
|
|||
tools: toolMaterialization?.definitions ?? [],
|
||||
toolChoice: isLastStep ? "none" : undefined,
|
||||
})
|
||||
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
|
||||
if (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }))
|
||||
return yield* Effect.die(continueAfterCompaction(currentStep))
|
||||
const startSnapshot = yield* snapshots.capture()
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
|
|
@ -224,9 +222,9 @@ export const layer = Layer.effect(
|
|||
},
|
||||
snapshot: startSnapshot,
|
||||
})
|
||||
const withPublication = Semaphore.makeUnsafe(1).withPermit
|
||||
const publication = Semaphore.makeUnsafe(1)
|
||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
|
||||
withPublication(publisher.publish(event, outputPaths))
|
||||
publication.withPermit(publisher.publish(event, outputPaths))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
|
|
@ -241,7 +239,9 @@ export const layer = Layer.effect(
|
|||
yield* publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
if (!toolMaterialization) {
|
||||
yield* withPublication(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"))
|
||||
yield* publication.withPermit(
|
||||
publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"),
|
||||
)
|
||||
return
|
||||
}
|
||||
needsContinuation = true
|
||||
|
|
@ -270,7 +270,7 @@ export const layer = Layer.effect(
|
|||
).pipe(FiberSet.run(toolFibers))
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(withPublication(publisher.flush())),
|
||||
Effect.ensuring(publication.withPermit(publisher.flush())),
|
||||
)
|
||||
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
|
|
@ -282,14 +282,14 @@ export const layer = Layer.effect(
|
|||
recoverOverflow &&
|
||||
!publisher.hasAssistantStarted() &&
|
||||
isContextOverflowFailure(overflowFailure ?? failure) &&
|
||||
(yield* restore(recoverOverflow({ sessionID: session.id, entries, model, request })))
|
||||
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
|
||||
)
|
||||
return yield* Effect.die(continueAfterOverflowCompaction(currentStep))
|
||||
if (overflowFailure) yield* publish(overflowFailure)
|
||||
const llmFailure = failure instanceof LLMError ? failure : undefined
|
||||
if (llmFailure && !publisher.hasProviderError()) {
|
||||
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
yield* withPublication(publisher.failAssistant(llmFailure.reason.message))
|
||||
yield* publication.withPermit(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
yield* publication.withPermit(publisher.failAssistant(llmFailure.reason.message))
|
||||
}
|
||||
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
|
||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
||||
|
|
@ -297,19 +297,19 @@ export const layer = Layer.effect(
|
|||
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
|
||||
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
|
||||
yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* publication.withPermit(publisher.failAssistant("Provider turn interrupted"))
|
||||
return yield* Effect.interrupt
|
||||
}
|
||||
if (streamInterrupted || toolsInterrupted) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
|
||||
yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* publication.withPermit(publisher.failAssistant("Provider turn interrupted"))
|
||||
}
|
||||
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
|
||||
const failure = Cause.squash(settled.cause)
|
||||
const message = failure instanceof Error ? failure.message : String(failure)
|
||||
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
||||
yield* publication.withPermit(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
||||
}
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
if (stepSettlement && !streamInterrupted && !toolsInterrupted && !publisher.hasProviderError()) {
|
||||
|
|
@ -320,7 +320,7 @@ export const layer = Layer.effect(
|
|||
.files({ from: startSnapshot, to: endSnapshot })
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
yield* withPublication(
|
||||
yield* publication.withPermit(
|
||||
events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: session.id,
|
||||
timestamp: yield* DateTime.now,
|
||||
|
|
@ -334,9 +334,9 @@ export const layer = Layer.effect(
|
|||
)
|
||||
}
|
||||
if (publisher.hasProviderError())
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
if (stream._tag === "Success" && !publisher.hasProviderError())
|
||||
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
yield* publication.withPermit(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
|
||||
return yield* Effect.failCause(settled.cause)
|
||||
|
|
@ -425,7 +425,7 @@ export const node = makeLocationNode({
|
|||
SystemContextRegistry.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
Config.node,
|
||||
SessionCompaction.node,
|
||||
Snapshot.node,
|
||||
Database.node,
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue