@ambasada/village 0.8.10 → 0.8.12
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/dist/cli.mjs +238 -64
- package/dist/client.d.ts +43 -3
- package/dist/index.js +47 -4
- package/dist/types.d.ts +97 -2
- package/package.json +3 -3
package/dist/client.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Ambasada, type AmbasadaOptions } from "@ambasada/sdk/platform";
|
|
2
2
|
import { type IncomingRequest } from "./receipt.js";
|
|
3
|
-
import type { JobAnswer, AgentConfigInput, BuyOptions, BuyResult, ChatRequest, ChatResponse, CheckoutInput, CheckoutReceiptClaims, CheckoutDelivery, CheckoutDeliveryInput, CheckoutSession, ImageRequest, ImageResult, LedgerPage, Listing, ListingInput, MarketOffering, PaidCall, PaymentLink, PaymentLinkInput, ToolListing, ToolResult, TranscriptEntry, ChatLine, ChatProposal, ChatDecision, ChatTurn, TransferOptions, Wallet, WebhookDelivery, WebhookEvent } from "./types.js";
|
|
3
|
+
import type { JobAnswer, AgentConfigInput, BuyOptions, BuyResult, ChatRequest, ChatResponse, CheckoutInput, CheckoutReceiptClaims, CheckoutDelivery, CheckoutDeliveryInput, CheckoutSession, ImageRequest, ImageResult, LedgerPage, Listing, ListingInput, MarketOffering, PaidCall, PaymentLink, PaymentLinkInput, ToolListing, ToolResult, TranscriptEntry, AgentState, AgentLogEntry, TalkDeal, TalkMessage, TalkThread, ChatLine, ChatProposal, ChatDecision, ChatTurn, TransferOptions, Wallet, WebhookDelivery, WebhookEvent } from "./types.js";
|
|
4
4
|
/** How long a utility call may take here: the API's own limit plus a margin, so the SDK never gives up on a call the API finishes and charges. */
|
|
5
5
|
export declare const CLIENT_TIMEOUT_MS: {
|
|
6
6
|
readonly llm: number;
|
|
@@ -53,11 +53,23 @@ export declare class Village extends Ambasada {
|
|
|
53
53
|
/**
|
|
54
54
|
* Wraps a paid offering as a fetch handler: verifies the receipt (402 if missing or bad), runs
|
|
55
55
|
* `handler(input, call, req)` and answers JSON. A thrown `AmbasadaError` keeps its status (the
|
|
56
|
-
* Gateway then refunds the buyer).
|
|
56
|
+
* Gateway then refunds the buyer). The buyer pays only for what you deliver, so a handler that returns an error
|
|
57
|
+
* object (`{ error: { code, message } }`) or nothing (`undefined`, `null`, `""`) is answered as an error too, never
|
|
58
|
+
* 200 (dry run 7 #8: "busy, try again" answers were charged). If you can't deliver the paid thing (a tool you
|
|
59
|
+
* need is rate-limited, CAP_TOOLS_RATE, or down, TOOL_UNAVAILABLE), throw, or rethrow what you caught: the buyer
|
|
60
|
+
* is refunded and told when to try again.
|
|
57
61
|
*
|
|
58
62
|
* export default village.sell("translate", async ({ text, to }) => ({ text: await village.llm(`…`) }));
|
|
59
63
|
*/
|
|
60
64
|
sell<Input = any, Output = unknown>(offering: string, handler: SellHandler<Input, Output>): (req: Request) => Promise<Response>;
|
|
65
|
+
/**
|
|
66
|
+
* A warning worth seeing in `ambasada logs` even though the request succeeded, e.g. an answer degraded because a
|
|
67
|
+
* tool was rate-limited: `console.warn("[village]", …)`. The templates send a request's `[village]` lines along with
|
|
68
|
+
* any answer, and ambasada.dev keeps them as warnings (at most 50 an hour; never counted as errors).
|
|
69
|
+
*
|
|
70
|
+
* village.warn("wikipedia.search: CAP_TOOLS_RATE, answering from the model alone");
|
|
71
|
+
*/
|
|
72
|
+
warn(...message: unknown[]): void;
|
|
61
73
|
/**
|
|
62
74
|
* An async offering's answer to a paid call (`async: true` in offerings.ts): the work goes on as job `id` (in the
|
|
63
75
|
* template, a Jobs Durable Object named after it), and the Gateway asks `GET <endpoint>/jobs/<id>` until it is done.
|
|
@@ -227,7 +239,35 @@ export declare class Village extends Ambasada {
|
|
|
227
239
|
more?: number;
|
|
228
240
|
earlier?: number;
|
|
229
241
|
}>;
|
|
230
|
-
|
|
242
|
+
/** How the agent is doing: its own status sentence and needs, why it last woke, the next wake, today's thinking. */
|
|
243
|
+
state: () => Promise<AgentState>;
|
|
244
|
+
/** The Inbox: one thread per company your agent talks with, newest first, with your unread counts. */
|
|
245
|
+
inbox: () => Promise<{
|
|
246
|
+
threads: TalkThread[];
|
|
247
|
+
unread: number;
|
|
248
|
+
}>;
|
|
249
|
+
/** One talk, both directions, oldest first, and the deals between you; reading it clears its unread count. */
|
|
250
|
+
thread: (company: string) => Promise<{
|
|
251
|
+
company: string;
|
|
252
|
+
name: string;
|
|
253
|
+
messages: TalkMessage[];
|
|
254
|
+
deals: TalkDeal[];
|
|
255
|
+
unread: number;
|
|
256
|
+
}>;
|
|
257
|
+
/** A word to your own agent about its talk with `company`, read on its next wake; never sent to them. */
|
|
258
|
+
whisper: (company: string, text: string) => Promise<{
|
|
259
|
+
id: string;
|
|
260
|
+
at: string;
|
|
261
|
+
next_wake_at?: string | null;
|
|
262
|
+
}>;
|
|
263
|
+
/** The Log: every wake with its transcript, steer, whispers, settings and approved proposals; 50 a page, newest first. */
|
|
264
|
+
log: (page?: number, kind?: "all" | "wakes" | "money" | "talks" | "issues") => Promise<{
|
|
265
|
+
entries: AgentLogEntry[];
|
|
266
|
+
page: number;
|
|
267
|
+
pages: number;
|
|
268
|
+
total: number;
|
|
269
|
+
per_page: number;
|
|
270
|
+
}>;
|
|
231
271
|
};
|
|
232
272
|
}
|
|
233
273
|
export declare function createVillage(options?: VillageOptions): Village;
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ __export(src_exports, {
|
|
|
13
13
|
APP_TIMESTAMP_HEADER: () => APP_TIMESTAMP_HEADER,
|
|
14
14
|
APP_WEBHOOK_TOLERANCE_S: () => APP_WEBHOOK_TOLERANCE_S,
|
|
15
15
|
ASYNC_OFFERING: () => ASYNC_OFFERING,
|
|
16
|
+
AUTH_SHELL_PATH: () => AUTH_SHELL_PATH,
|
|
16
17
|
AppWebhookError: () => AppWebhookError,
|
|
17
18
|
DEFAULT_HUB_URL: () => DEFAULT_HUB_URL,
|
|
18
19
|
DEFAULT_ISSUER: () => DEFAULT_ISSUER,
|
|
@@ -1137,6 +1138,7 @@ function appNotifyResult(json) {
|
|
|
1137
1138
|
|
|
1138
1139
|
// ../protocol/src/check.ts
|
|
1139
1140
|
var MANIFEST_URL_PATH = "/.well-known/ambasada.json";
|
|
1141
|
+
var AUTH_SHELL_PATH = "/api/auth/shell";
|
|
1140
1142
|
var MANIFEST_SCHEMA = 2;
|
|
1141
1143
|
var MANIFEST_KINDS = ["product", "event", "tool", "assistant"];
|
|
1142
1144
|
var MANIFEST_CATEGORIES = ["shopping", "food", "events", "tools", "learning", "community", "games", "finance", "other"];
|
|
@@ -1400,6 +1402,15 @@ async function checkRemote(url, fetchImpl = fetch, options = {}) {
|
|
|
1400
1402
|
report.problems.push({ code: "identity_callback", message: `could not fetch ${callbackUrl}: ${error.message}` });
|
|
1401
1403
|
}
|
|
1402
1404
|
}
|
|
1405
|
+
if (scopes.includes("identity")) {
|
|
1406
|
+
const shellUrl = new URL(AUTH_SHELL_PATH, origin).toString();
|
|
1407
|
+
try {
|
|
1408
|
+
const res = await get(fetchImpl, shellUrl);
|
|
1409
|
+
await res.body?.cancel();
|
|
1410
|
+
if (res.status === 404) report.warnings.push({ code: "identity_shell", message: `the identity scope signs people in on open through ${AUTH_SHELL_PATH} (auth.shell(req) from @ambasada/sdk); ${shellUrl} answered 404, so inside the Ambasada app they get "Sign in with Ambasada" instead` });
|
|
1411
|
+
} catch {
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1403
1414
|
const webhook = manifest?.notifications && typeof manifest.notifications === "object" ? manifest.notifications.webhook : void 0;
|
|
1404
1415
|
if (scopes.includes("notifications") && typeof webhook === "string" && webhook) {
|
|
1405
1416
|
const webhookUrl = new URL(webhook, origin);
|
|
@@ -1435,7 +1446,7 @@ var LABELS = [
|
|
|
1435
1446
|
[/^entry_|^not_https$|^bad_url$/, "entry page"],
|
|
1436
1447
|
[/^privacy_url$/, "privacy page"],
|
|
1437
1448
|
[/^notifications_webhook$|^webhook_/, "webhook"],
|
|
1438
|
-
[/^
|
|
1449
|
+
[/^identity_(callback|shell)$/, "sign-in"]
|
|
1439
1450
|
];
|
|
1440
1451
|
function findingLabel(code) {
|
|
1441
1452
|
return LABELS.find(([re]) => re.test(code))?.[1] ?? code.replace(/_/g, " ");
|
|
@@ -1749,7 +1760,11 @@ var Village = class extends Ambasada {
|
|
|
1749
1760
|
/**
|
|
1750
1761
|
* Wraps a paid offering as a fetch handler: verifies the receipt (402 if missing or bad), runs
|
|
1751
1762
|
* `handler(input, call, req)` and answers JSON. A thrown `AmbasadaError` keeps its status (the
|
|
1752
|
-
* Gateway then refunds the buyer).
|
|
1763
|
+
* Gateway then refunds the buyer). The buyer pays only for what you deliver, so a handler that returns an error
|
|
1764
|
+
* object (`{ error: { code, message } }`) or nothing (`undefined`, `null`, `""`) is answered as an error too, never
|
|
1765
|
+
* 200 (dry run 7 #8: "busy, try again" answers were charged). If you can't deliver the paid thing (a tool you
|
|
1766
|
+
* need is rate-limited, CAP_TOOLS_RATE, or down, TOOL_UNAVAILABLE), throw, or rethrow what you caught: the buyer
|
|
1767
|
+
* is refunded and told when to try again.
|
|
1753
1768
|
*
|
|
1754
1769
|
* export default village.sell("translate", async ({ text, to }) => ({ text: await village.llm(`…`) }));
|
|
1755
1770
|
*/
|
|
@@ -1765,7 +1780,16 @@ var Village = class extends Ambasada {
|
|
|
1765
1780
|
}
|
|
1766
1781
|
try {
|
|
1767
1782
|
const out = await handler(call.input, call, req);
|
|
1768
|
-
|
|
1783
|
+
if (out instanceof Response) return out;
|
|
1784
|
+
if (out === void 0 || out === null || out === "") {
|
|
1785
|
+
return new AmbasadaError3("UPSTREAM_ERROR", `the ${offering2} handler returned nothing; the buyer is refunded (return what was paid for, or throw)`, 502).toResponse();
|
|
1786
|
+
}
|
|
1787
|
+
const error = out.error;
|
|
1788
|
+
if (typeof error === "object" && error !== null && typeof error.code === "string") {
|
|
1789
|
+
const { code, message, ...details } = error;
|
|
1790
|
+
return new AmbasadaError3(code, typeof message === "string" ? message : code, void 0, details).toResponse();
|
|
1791
|
+
}
|
|
1792
|
+
return Response.json(out);
|
|
1769
1793
|
} catch (error) {
|
|
1770
1794
|
if (error instanceof AmbasadaError3) return error.toResponse();
|
|
1771
1795
|
console.error(error);
|
|
@@ -1773,6 +1797,16 @@ var Village = class extends Ambasada {
|
|
|
1773
1797
|
}
|
|
1774
1798
|
};
|
|
1775
1799
|
}
|
|
1800
|
+
/**
|
|
1801
|
+
* A warning worth seeing in `ambasada logs` even though the request succeeded, e.g. an answer degraded because a
|
|
1802
|
+
* tool was rate-limited: `console.warn("[village]", …)`. The templates send a request's `[village]` lines along with
|
|
1803
|
+
* any answer, and ambasada.dev keeps them as warnings (at most 50 an hour; never counted as errors).
|
|
1804
|
+
*
|
|
1805
|
+
* village.warn("wikipedia.search: CAP_TOOLS_RATE, answering from the model alone");
|
|
1806
|
+
*/
|
|
1807
|
+
warn(...message) {
|
|
1808
|
+
console.warn("[village]", ...message);
|
|
1809
|
+
}
|
|
1776
1810
|
/**
|
|
1777
1811
|
* An async offering's answer to a paid call (`async: true` in offerings.ts): the work goes on as job `id` (in the
|
|
1778
1812
|
* template, a Jobs Durable Object named after it), and the Gateway asks `GET <endpoint>/jobs/<id>` until it is done.
|
|
@@ -2046,7 +2080,16 @@ var Village = class extends Ambasada {
|
|
|
2046
2080
|
* `more`: entries after this page (read on from `next_since`); `earlier`: with `newest`, the ones before it.
|
|
2047
2081
|
*/
|
|
2048
2082
|
transcript: (since = 0, limit = 200, options = {}) => this.call("GET", "/v1/agent/transcript", { query: { since, limit, ...options.newest ? { newest: 1 } : {} } }),
|
|
2049
|
-
|
|
2083
|
+
/** How the agent is doing: its own status sentence and needs, why it last woke, the next wake, today's thinking. */
|
|
2084
|
+
state: () => this.call("GET", "/v1/agent/state"),
|
|
2085
|
+
/** The Inbox: one thread per company your agent talks with, newest first, with your unread counts. */
|
|
2086
|
+
inbox: () => this.call("GET", "/v1/agent/inbox/threads"),
|
|
2087
|
+
/** One talk, both directions, oldest first, and the deals between you; reading it clears its unread count. */
|
|
2088
|
+
thread: (company) => this.call("GET", `/v1/agent/inbox/threads/${encodeURIComponent(company)}`),
|
|
2089
|
+
/** A word to your own agent about its talk with `company`, read on its next wake; never sent to them. */
|
|
2090
|
+
whisper: (company, text) => this.call("POST", "/v1/agent/whisper", { body: { company, text } }),
|
|
2091
|
+
/** The Log: every wake with its transcript, steer, whispers, settings and approved proposals; 50 a page, newest first. */
|
|
2092
|
+
log: (page = 1, kind = "all") => this.call("GET", "/v1/agent/log", { query: { page, kind } })
|
|
2050
2093
|
};
|
|
2051
2094
|
};
|
|
2052
2095
|
function createVillage(options = {}) {
|
package/dist/types.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface ReceiptClaims {
|
|
|
17
17
|
/** TON you may still spend downstream on behalf of this call. */
|
|
18
18
|
budget?: number;
|
|
19
19
|
deal_id?: string;
|
|
20
|
-
/** An async offering's call: when the buyer's max_wait_s runs out (unix seconds); `startJob(…, { deadline: call.deadline
|
|
20
|
+
/** An async offering's call: when the buyer's max_wait_s runs out (unix seconds); pass it as it is: `startJob(…, { deadline: call.deadline })` (the templates' jobs take seconds or ms). */
|
|
21
21
|
deadline?: number;
|
|
22
22
|
body_sha256: string;
|
|
23
23
|
iat: number;
|
|
@@ -159,7 +159,7 @@ export interface BuyOptions {
|
|
|
159
159
|
}
|
|
160
160
|
/** What an async offering's `GET <endpoint>/jobs/<id>` says (village.jobStatus): the Gateway settles on done or failed. */
|
|
161
161
|
export type JobAnswer =
|
|
162
|
-
/** `attempts`: the failed ones so far (the template's `jobAnswer(job)`); the buyer sees "working (attempt
|
|
162
|
+
/** `attempts`: the failed ones so far (the template's `jobAnswer(job)`); the buyer sees "working (attempt 1 failed: …; retrying)". */
|
|
163
163
|
{
|
|
164
164
|
status: "working";
|
|
165
165
|
attempts?: {
|
|
@@ -332,9 +332,14 @@ export interface TransferOptions {
|
|
|
332
332
|
export interface AgentSettings {
|
|
333
333
|
model?: "deepseek-v4.1-flash";
|
|
334
334
|
enabled?: boolean;
|
|
335
|
+
/**
|
|
336
|
+
* strategy_hours: a scheduled wake with nothing new (no message, sale, answer, finished purchase or rival move) costs
|
|
337
|
+
* nothing and runs no model, except once this many hours after the last wake that thought (default 6).
|
|
338
|
+
*/
|
|
335
339
|
wake?: {
|
|
336
340
|
interval_minutes?: number;
|
|
337
341
|
min_event_gap_seconds?: number;
|
|
342
|
+
strategy_hours?: number;
|
|
338
343
|
};
|
|
339
344
|
budget?: {
|
|
340
345
|
tool_calls_per_wake?: number;
|
|
@@ -344,6 +349,8 @@ export interface AgentSettings {
|
|
|
344
349
|
};
|
|
345
350
|
/** Agent tools offered to the model (default: all). */
|
|
346
351
|
tools?: string[];
|
|
352
|
+
/** A wake changes one offering's price at most this often, in minutes (default 120; 0: any time). */
|
|
353
|
+
price_cooldown_minutes?: number;
|
|
347
354
|
/**
|
|
348
355
|
* The whole company's spend limits, TON: the product's own LLM and image calls count too, not only the agent's
|
|
349
356
|
* purchases. Over `auto_per_payment` (and `auto_per_day`) an agent payment needs the team's approval.
|
|
@@ -427,6 +434,94 @@ export interface ChatDecision {
|
|
|
427
434
|
/** With `wait`: the agent's next line (its reply, or the "no reply" card), null when a minute went by. */
|
|
428
435
|
follow_up?: ChatLine | null;
|
|
429
436
|
}
|
|
437
|
+
/** `agent.state()`: how the agent is doing (docs/AGENT-CHAT.md v2, the app's status card). */
|
|
438
|
+
export interface AgentState {
|
|
439
|
+
configured: boolean;
|
|
440
|
+
enabled: boolean;
|
|
441
|
+
/** asleep | working */
|
|
442
|
+
status: string;
|
|
443
|
+
/** The agent's own sentence on where things stand (it sets it at the end of every wake that ran), and what it needs. */
|
|
444
|
+
status_text?: string | null;
|
|
445
|
+
status_set_at?: string | null;
|
|
446
|
+
needs?: {
|
|
447
|
+
kind: "offering" | "approval" | "limit" | "deploy";
|
|
448
|
+
label: string;
|
|
449
|
+
}[];
|
|
450
|
+
woke_last_because?: string | null;
|
|
451
|
+
/** The free checks since the last wake found nothing new: no model ran, nothing was charged. */
|
|
452
|
+
idle?: {
|
|
453
|
+
nothing_new_since: string | null;
|
|
454
|
+
checked_at: string | null;
|
|
455
|
+
next_check_at: string | null;
|
|
456
|
+
} | null;
|
|
457
|
+
last_wake?: {
|
|
458
|
+
id: string;
|
|
459
|
+
at: string;
|
|
460
|
+
ended_at: string | null;
|
|
461
|
+
stop: string | null;
|
|
462
|
+
summary: string | null;
|
|
463
|
+
woke_because?: string | null;
|
|
464
|
+
} | null;
|
|
465
|
+
next_wake_at: string | null;
|
|
466
|
+
compute_today: number;
|
|
467
|
+
compute_cap_today: number | null;
|
|
468
|
+
[key: string]: unknown;
|
|
469
|
+
}
|
|
470
|
+
/** One message of a talk between your agent and another company: theirs (`in`), your agent's (`out`), your whisper. */
|
|
471
|
+
export interface TalkMessage {
|
|
472
|
+
id: string;
|
|
473
|
+
at: string;
|
|
474
|
+
direction: "in" | "out" | "whisper";
|
|
475
|
+
type: string;
|
|
476
|
+
text: string | null;
|
|
477
|
+
/** One plain line: the text, or "quotes 2.5 TON per call for pixelpal/stickers, up to 20 a day". */
|
|
478
|
+
summary: string;
|
|
479
|
+
ref: string | null;
|
|
480
|
+
}
|
|
481
|
+
export interface TalkDeal {
|
|
482
|
+
id: string;
|
|
483
|
+
offering: string;
|
|
484
|
+
price: number;
|
|
485
|
+
status: string;
|
|
486
|
+
role: "buyer" | "seller";
|
|
487
|
+
qty_cap: number;
|
|
488
|
+
used: number;
|
|
489
|
+
valid_until: string;
|
|
490
|
+
created_at: string;
|
|
491
|
+
}
|
|
492
|
+
/** A row of the Inbox: one company your agent talks with. */
|
|
493
|
+
export interface TalkThread {
|
|
494
|
+
company: string;
|
|
495
|
+
name: string;
|
|
496
|
+
last: TalkMessage;
|
|
497
|
+
unread: number;
|
|
498
|
+
messages: number;
|
|
499
|
+
deal: TalkDeal | null;
|
|
500
|
+
}
|
|
501
|
+
/** One entry of the agent's Log: a wake (with its transcript as `steps`), steer, a whisper, settings, a proposal. */
|
|
502
|
+
export interface AgentLogEntry {
|
|
503
|
+
id: string;
|
|
504
|
+
at: string;
|
|
505
|
+
type: "wake" | "steer" | "whisper" | "config" | "proposal";
|
|
506
|
+
kinds: ("wakes" | "money" | "talks" | "issues")[];
|
|
507
|
+
title: string;
|
|
508
|
+
summary: string | null;
|
|
509
|
+
cost_ton: number | null;
|
|
510
|
+
wake?: {
|
|
511
|
+
id: string;
|
|
512
|
+
reason: string;
|
|
513
|
+
woke_because: string | null;
|
|
514
|
+
stop: string | null;
|
|
515
|
+
ended_at: string | null;
|
|
516
|
+
tool_calls: number | null;
|
|
517
|
+
};
|
|
518
|
+
steps?: {
|
|
519
|
+
seq: number;
|
|
520
|
+
at: string;
|
|
521
|
+
kind: string;
|
|
522
|
+
[key: string]: unknown;
|
|
523
|
+
}[];
|
|
524
|
+
}
|
|
430
525
|
export interface MarketOffering {
|
|
431
526
|
id: string;
|
|
432
527
|
company?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ambasada/village",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.12",
|
|
4
4
|
"description": "The Hack A Ton add-on for Ambasada apps: TON checkout, receipts, offerings, LLM, the agent, the market; CLI commands and the local bank.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"node": ">=22"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@ambasada/sdk": "^0.8.
|
|
30
|
+
"@ambasada/sdk": "^0.8.12"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "^22.20.5",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"typescript": "~5.9.3",
|
|
36
36
|
"vitest": "^4.1.12",
|
|
37
37
|
"@ambasada/protocol": "0.1.0",
|
|
38
|
-
"@ambasada/cli": "0.8.
|
|
38
|
+
"@ambasada/cli": "0.8.12"
|
|
39
39
|
},
|
|
40
40
|
"author": "CENTYSO (https://centyso.com)",
|
|
41
41
|
"homepage": "https://ambasada.id",
|