Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress
This commit is contained in:
@@ -11,14 +11,16 @@ import { basename, resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite'
|
||||
import GoalService from '@deepseek-ai/dsh-goal'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -28,14 +30,20 @@ import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
|
||||
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
|
||||
import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control'
|
||||
import * as ToolSubagentListAgents from '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
||||
import * as ToolSubagentReport from '@deepseek-ai/dsh-tool-subagent-report'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
import PtyService from '@deepseek-ai/dsh-pty'
|
||||
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
|
||||
import * as ToolGoal from '@deepseek-ai/dsh-tool-goal'
|
||||
@@ -53,44 +61,6 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/tool-catalog.md'
|
||||
const CATALOG_RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
|
||||
|
||||
/**
|
||||
* Minimal bash service for harvesting `dsh-tool-fs-search` schemas. The search
|
||||
* plugin now probes `rg` at registration time, but the generated catalog must
|
||||
* remain independent of the host PATH and never execute a real search.
|
||||
*/
|
||||
class CatalogSearchBashExecutor extends BashExecutor {
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? root,
|
||||
timeoutMs: request.timeoutMs ?? 60_000,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
signal: request.signal,
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
override run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
if (spec.command !== CATALOG_RG_PROBE_COMMAND) {
|
||||
throw new Error(`gen-tool-catalog: unexpected search bash command during schema harvest: ${spec.command}`)
|
||||
}
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: spec.timeoutMs,
|
||||
stdout: { text: '', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
})
|
||||
}
|
||||
|
||||
override start(): BashProcess {
|
||||
throw new Error('gen-tool-catalog: search schema harvest must not start background processes')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the descriptor needed to mount schema-producing consumers. Declares
|
||||
@@ -104,10 +74,32 @@ function registerCatalogSubagentProvider(ctx: Context, name: string): void {
|
||||
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
|
||||
inheritsParentContext: false,
|
||||
start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')),
|
||||
// Declared so consumers configured for continuable background mode mount.
|
||||
prepareContinuable: () => Promise.reject(new Error('tool-catalog provider cannot prepare a child')),
|
||||
}
|
||||
ctx.subagents.registerProvider(provider)
|
||||
}
|
||||
|
||||
/** Minted child-scope keys for packages whose tools are never global. */
|
||||
const catalogChildScopes = new WeakMap<Context, Agent>()
|
||||
|
||||
/**
|
||||
* Install one scope-local tool package into an agent-like child scope for
|
||||
* schema harvest, without starting a model, Agent loop, or persistence backend.
|
||||
* @param ctx - catalog context owning the scope.
|
||||
* @param mountScoped - package installer for the scoped context.
|
||||
*/
|
||||
async function mountCatalogChildScope(
|
||||
ctx: Context,
|
||||
mountScoped: (childCtx: Context) => void,
|
||||
): Promise<void> {
|
||||
const key = { id: SessionId('tool-catalog-child') } as Agent
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
mountScoped(createScope(inner, key).ctx)
|
||||
}, { inject: ['tools', 'systemPrompt', 'subagents'] }))
|
||||
catalogChildScopes.set(ctx, key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool package plus its hand-maintained boot recipe. The caller mounts the
|
||||
* prompt and registry; each recipe supplies only package-specific seams and
|
||||
@@ -118,8 +110,12 @@ interface ToolPackage {
|
||||
pkg: string
|
||||
/** The `packages/<group>/<dir>` leaf name — matched by the completeness guard. */
|
||||
dir: string
|
||||
/** Repo-relative source path linked from the catalog entry. */
|
||||
source: string
|
||||
/**
|
||||
* Repo-relative implementation source linked per harvested tool. Packages
|
||||
* whose tools share one plugin may use a string; split plugins map each tool
|
||||
* name to its own source.
|
||||
*/
|
||||
source: string | Readonly<Record<string, string>>
|
||||
/** Services or owning runtime surfaces the package requires at execution time. */
|
||||
requires: string[]
|
||||
/** Session events or other visible state the tools write or affect. */
|
||||
@@ -129,6 +125,8 @@ interface ToolPackage {
|
||||
/** Plug the injected seams + the tool plugin onto a context that already
|
||||
* carries `systemPrompt` + `tools`. */
|
||||
mount: (ctx: Context) => Promise<void>
|
||||
/** Agent-like scope key whose tool view is catalogued instead of the global view. */
|
||||
scope?: (ctx: Context) => Agent
|
||||
/**
|
||||
* Config for the caller's `ToolRegistry` mount. The registry itself ships a
|
||||
* model-facing tool (`run_code`, registered under a non-native `mode`), so
|
||||
@@ -195,16 +193,35 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-tool-bash',
|
||||
dir: 'tool-bash',
|
||||
source: 'packages/bash/tool-bash/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'],
|
||||
requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt', 'ctx.bashEnv', 'ctx.tasks at call time for run_in_background'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(LocalBashExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
},
|
||||
note:
|
||||
'The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-pwsh',
|
||||
dir: 'tool-pwsh',
|
||||
source: 'packages/bash/tool-pwsh/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt', 'ctx.bashEnv', 'ctx.tasks at call time for run_in_background'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The pwsh tool consumes the bash executor seam; the schema harvest
|
||||
// mounts the pwsh-local implementation so the inject resolves without
|
||||
// executing anything (registration never spawns a process).
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(PwshLocalExecutor)
|
||||
await ctx.plugin(ToolPwsh)
|
||||
},
|
||||
note:
|
||||
'The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\\...` paths and `$env:NAME` variables.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-cordis',
|
||||
dir: 'tool-cordis',
|
||||
@@ -215,7 +232,33 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolCordis)
|
||||
},
|
||||
note:
|
||||
'Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes.',
|
||||
'Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-bash-persistent',
|
||||
dir: 'tool-bash-persistent',
|
||||
source: 'packages/pty/tool-bash-persistent/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.pty', 'an owning Agent at execution time'],
|
||||
writes: ['tool/call', 'PTY shell state', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(PtyService)
|
||||
await ctx.plugin(ToolBashPersistent)
|
||||
},
|
||||
note:
|
||||
'One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-str-replace-editor',
|
||||
dir: 'tool-str-replace-editor',
|
||||
source: 'packages/fs/tool-str-replace-editor/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.fs'],
|
||||
writes: ['tool/call', 'fs/observed after successful file operations', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(LocalFileSystem)
|
||||
await ctx.plugin(ToolStrReplaceEditor)
|
||||
},
|
||||
note:
|
||||
'Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-fs',
|
||||
@@ -236,19 +279,19 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-tool-fs-search',
|
||||
dir: 'tool-fs-search',
|
||||
source: 'packages/fs/tool-fs-search/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt'],
|
||||
requires: ['ctx.tools', 'ctx.subprocess', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The tools inject `bash` (search executes fixed `rg` commands through
|
||||
// the executor seam, not ctx.fs). Use a catalog-only executor so the
|
||||
// registration-time `rg` probe stays deterministic and the generator
|
||||
// never depends on the host PATH. `ctx.spillStore` is optional (read via
|
||||
// ctx.get) and does not affect the schemas, so no spill backend is mounted.
|
||||
await ctx.plugin(CatalogSearchBashExecutor)
|
||||
await ctx.plugin(ToolFsSearch)
|
||||
// The tools inject `subprocess` (search spawns the packaged ripgrep
|
||||
// binary through the seam, not ctx.fs); registration itself never
|
||||
// spawns, so the real local service is inert here. `ctx.spillStore` is
|
||||
// optional (read via ctx.get) and does not affect the schemas, so no
|
||||
// spill backend is mounted.
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true })
|
||||
},
|
||||
note:
|
||||
'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.',
|
||||
'glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-pty',
|
||||
@@ -268,7 +311,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
dir: 'tool-goal',
|
||||
source: 'packages/goal/tool-goal/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.agents', 'ctx.goals', 'ctx.systemPrompt', 'a calling Agent in an authorized open turn'],
|
||||
writes: ['tool/call', 'user/message goal snapshot for mutations', 'tool/result'],
|
||||
writes: ['tool/call', 'goal/change for mutations', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(GoalService)
|
||||
@@ -310,9 +353,10 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-tool-skill',
|
||||
dir: 'tool-skill',
|
||||
source: 'packages/skill/tool-skill/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.skills'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
requires: ['ctx.tools', 'ctx.agents', 'ctx.skills'],
|
||||
writes: ['tool/call', 'tool/result', 'user/message replacement catalogs via agent.inject()'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, {
|
||||
dshHome: resolve(root, '.tmp/tool-catalog/.dsh'),
|
||||
@@ -348,7 +392,47 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolSubagent, { provider: 'mock' })
|
||||
},
|
||||
note:
|
||||
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.',
|
||||
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent-control',
|
||||
dir: 'tool-subagent-control',
|
||||
source: {
|
||||
list_agents: 'packages/subagent/tool-subagent-control/src/list-agents.ts',
|
||||
send_message: 'packages/subagent/tool-subagent-control/src/index.ts',
|
||||
},
|
||||
requires: ['ctx.tools', 'ctx.subagents', 'ctx.sessionQuery (list_agents only)'],
|
||||
writes: ['tool/call', 'tool/result', 'child session events through ctx.subagents'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionQuerySqlite, { path: ':memory:' })
|
||||
await ctx.plugin(ToolSubagentControl)
|
||||
await ctx.plugin(ToolSubagentListAgents)
|
||||
},
|
||||
note:
|
||||
'The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (which additionally requires session query).',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent-report',
|
||||
dir: 'tool-subagent-report',
|
||||
source: 'packages/subagent/tool-subagent-report/src/index.ts',
|
||||
requires: ['ctx.subagents', 'a live continuable in-process child Agent'],
|
||||
writes: ['tool/call', 'tool/result', 'a user-role message in the direct parent session'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
await mountCatalogChildScope(ctx, (childCtx) => {
|
||||
ToolSubagentReport.installReportTool(childCtx, ctx, 'quiet')
|
||||
})
|
||||
},
|
||||
scope: ctx => catalogChildScopes.get(ctx) as Agent,
|
||||
note:
|
||||
'Registered per continuable in-process child rather than globally, so this schema is visible only '
|
||||
+ 'inside such a child and survives its global `toolFilter`. The parent-facing `send_message` tool '
|
||||
+ 'is installed independently.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-tasks',
|
||||
@@ -413,7 +497,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
/** One package's contribution to the catalog: its schemas plus attribution. */
|
||||
interface CatalogPackage {
|
||||
pkg: string
|
||||
source: string
|
||||
sources: Readonly<Record<string, string>>
|
||||
requires: string[]
|
||||
writes: string[]
|
||||
shippedNames?: string[]
|
||||
@@ -465,10 +549,13 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry, entry.toolsConfig ?? {})
|
||||
await entry.mount(ctx)
|
||||
const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name))
|
||||
const schemas = ctx.tools.schemas(entry.scope?.(ctx)).sort((a, b) => a.name.localeCompare(b.name))
|
||||
catalog.push({
|
||||
pkg: entry.pkg,
|
||||
source: entry.source,
|
||||
sources: Object.fromEntries(schemas.map(schema => [
|
||||
schema.name,
|
||||
toolSource(entry, schema.name),
|
||||
])),
|
||||
requires: entry.requires,
|
||||
writes: entry.writes,
|
||||
schemas,
|
||||
@@ -482,6 +569,18 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
|
||||
return catalog
|
||||
}
|
||||
|
||||
/** Resolve one harvested tool to the plugin source that registered it. */
|
||||
function toolSource(entry: ToolPackage, toolName: string): string {
|
||||
if (typeof entry.source === 'string') return entry.source
|
||||
const source = entry.source[toolName]
|
||||
if (source === undefined) {
|
||||
throw new Error(
|
||||
`gen-tool-catalog: ${entry.pkg} has no source mapping for harvested tool ${toolName}`,
|
||||
)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
/** Render one tool's entry: name, description, JSON-Schema parameters, source. */
|
||||
function renderTool(schema: ToolSchema, source: string): string[] {
|
||||
const out = [`### \`${schema.name}\``, '']
|
||||
@@ -524,7 +623,11 @@ export function render(catalog: ToolCatalog): string {
|
||||
]
|
||||
for (const entry of catalog) {
|
||||
lines.push(`## \`${entry.pkg}\``, '')
|
||||
for (const schema of entry.schemas) lines.push(...renderTool(schema, entry.source))
|
||||
for (const schema of entry.schemas) {
|
||||
// Collection validated that every harvested schema has a source.
|
||||
const source = entry.sources[schema.name] as string
|
||||
lines.push(...renderTool(schema, source))
|
||||
}
|
||||
if (entry.note) lines.push(entry.note, '')
|
||||
}
|
||||
return lines.join('\n')
|
||||
|
||||
Reference in New Issue
Block a user