volute 0.3.1 → 0.5.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.
Files changed (82) hide show
  1. package/README.md +29 -29
  2. package/dist/agent-Z2B6EFEQ.js +75 -0
  3. package/dist/{agent-manager-AUCKMGPR.js → agent-manager-PXBKA2GK.js} +4 -4
  4. package/dist/channel-MK5OK2SI.js +113 -0
  5. package/dist/chunk-5X7HGB6L.js +107 -0
  6. package/dist/{chunk-YGFIWIOF.js → chunk-7L4AN5D4.js} +1 -1
  7. package/dist/{chunk-VRVVQIYY.js → chunk-AZEL2IEK.js} +1 -1
  8. package/dist/chunk-B3R6L2GW.js +24 -0
  9. package/dist/{chunk-DNOXHLE5.js → chunk-HE67X4T6.js} +1 -1
  10. package/dist/{chunk-I6OHXCMV.js → chunk-MW2KFO3B.js} +47 -9
  11. package/dist/chunk-MXUCNIBG.js +168 -0
  12. package/dist/chunk-SMISE4SV.js +226 -0
  13. package/dist/{chunk-SOZA2TLP.js → chunk-UAVD2AHX.js} +1 -1
  14. package/dist/{chunk-3C2XR4IY.js → chunk-UX25Z2ND.js} +113 -107
  15. package/dist/{chunk-GSPKUPKU.js → chunk-XUA3JUFK.js} +2 -1
  16. package/dist/chunk-ZYGKG6VC.js +22 -0
  17. package/dist/cli.js +98 -75
  18. package/dist/connector-LYEMXQEV.js +157 -0
  19. package/dist/connectors/discord.js +104 -161
  20. package/dist/connectors/slack.js +179 -0
  21. package/dist/connectors/telegram.js +175 -0
  22. package/dist/conversation-ERXEQZTY.js +163 -0
  23. package/dist/create-RVCZN6HE.js +91 -0
  24. package/dist/{daemon-client-XR24PUJF.js → daemon-client-ZY6UUN2M.js} +2 -2
  25. package/dist/daemon.js +824 -252
  26. package/dist/{delete-GQ7JEK2S.js → delete-3QH7VYIN.js} +8 -9
  27. package/dist/{down-3OB6UVAJ.js → down-O7IFZLVJ.js} +1 -1
  28. package/dist/{env-JB27UAC3.js → env-4D4REPJF.js} +8 -5
  29. package/dist/{history-3VRUBGGV.js → history-OEONB53Z.js} +5 -5
  30. package/dist/{import-K4MP2GX7.js → import-MXJB2EII.js} +23 -8
  31. package/dist/{logs-NXFFGUKY.js → logs-DF342W4M.js} +2 -2
  32. package/dist/message-ADHWFHSI.js +32 -0
  33. package/dist/package-VQOE7JNH.js +89 -0
  34. package/dist/{schedule-4I5TYHFH.js → schedule-NAG6F463.js} +12 -7
  35. package/dist/send-66QMKRUH.js +75 -0
  36. package/dist/{setup-SRS7AUAA.js → setup-RPRRGG2F.js} +6 -6
  37. package/dist/{start-LDPMCMYT.js → start-TUOXDSFL.js} +3 -3
  38. package/dist/{status-MVSQG54T.js → status-A36EHRO4.js} +3 -3
  39. package/dist/{stop-5PZTZCLL.js → stop-AOJZLQ5X.js} +6 -7
  40. package/dist/{up-UT3IMKCA.js → up-7ILD7GU7.js} +2 -2
  41. package/dist/update-LPSIAWQ2.js +140 -0
  42. package/dist/update-check-Y33QDCFL.js +17 -0
  43. package/dist/{upgrade-CDKECCGN.js → upgrade-FX2TKJ2S.js} +16 -15
  44. package/dist/{variant-CVYM3EQG.js → variant-LAB67OC2.js} +17 -12
  45. package/dist/web-assets/assets/index-BbRmoxoA.js +308 -0
  46. package/dist/web-assets/index.html +2 -2
  47. package/drizzle/0003_clean_ego.sql +12 -0
  48. package/drizzle/meta/0003_snapshot.json +417 -0
  49. package/drizzle/meta/_journal.json +7 -0
  50. package/package.json +3 -1
  51. package/templates/_base/.init/.config/hooks/startup-context.sh +19 -1
  52. package/templates/_base/_skills/volute-agent/SKILL.md +112 -16
  53. package/templates/_base/home/.config/routes.json +10 -0
  54. package/templates/_base/home/VOLUTE.md +19 -28
  55. package/templates/_base/src/lib/file-handler.ts +46 -0
  56. package/templates/_base/src/lib/format-prefix.ts +1 -1
  57. package/templates/_base/src/lib/router.ts +327 -0
  58. package/templates/_base/src/lib/routing.ts +137 -0
  59. package/templates/_base/src/lib/types.ts +16 -3
  60. package/templates/_base/src/lib/volute-server.ts +20 -48
  61. package/templates/agent-sdk/.init/.config/routes.json +5 -0
  62. package/templates/agent-sdk/.init/CLAUDE.md +2 -2
  63. package/templates/agent-sdk/src/agent.ts +269 -82
  64. package/templates/agent-sdk/src/server.ts +19 -4
  65. package/templates/agent-sdk/volute-template.json +1 -1
  66. package/templates/pi/.init/.config/routes.json +5 -0
  67. package/templates/pi/.init/AGENTS.md +1 -1
  68. package/templates/pi/src/agent.ts +279 -58
  69. package/templates/pi/src/server.ts +15 -4
  70. package/templates/pi/volute-template.json +1 -1
  71. package/dist/channel-7FZ6D25H.js +0 -90
  72. package/dist/chunk-N4YNKR3Q.js +0 -90
  73. package/dist/connector-TVJULIRT.js +0 -96
  74. package/dist/create-BRG2DBWI.js +0 -79
  75. package/dist/send-UK3JBZIB.js +0 -53
  76. package/dist/web-assets/assets/index-BC5eSqbY.js +0 -296
  77. package/templates/_base/src/lib/sessions.ts +0 -71
  78. package/templates/agent-sdk/.init/.config/sessions.json +0 -4
  79. package/templates/agent-sdk/src/lib/agent-sessions.ts +0 -204
  80. package/templates/pi/.init/.config/sessions.json +0 -1
  81. package/templates/pi/src/lib/agent-sessions.ts +0 -210
  82. package/dist/{service-SA4TTMDU.js → service-HZNIDNJF.js} +3 -3
@@ -1,71 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
-
3
- export type SessionRule = {
4
- session: string;
5
- [key: string]: string; // all other keys are match criteria
6
- };
7
-
8
- export type SessionConfig = {
9
- rules?: SessionRule[];
10
- default?: string;
11
- };
12
-
13
- export function loadSessionConfig(configPath: string): SessionConfig {
14
- try {
15
- return JSON.parse(readFileSync(configPath, "utf-8"));
16
- } catch {
17
- return {};
18
- }
19
- }
20
-
21
- /**
22
- * Match a glob-like pattern against a string.
23
- * Supports only `*` as wildcard (matches any sequence of characters).
24
- */
25
- function globMatch(pattern: string, value: string): boolean {
26
- // Escape regex special chars except *, then replace * with .*
27
- const regex = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
28
- return new RegExp(`^${regex}$`).test(value);
29
- }
30
-
31
- /**
32
- * Resolve which session a message should route to based on the config.
33
- * Returns the session name (with template variables expanded, path-safe).
34
- */
35
- export function resolveSession(
36
- config: SessionConfig,
37
- meta: { channel?: string; sender?: string },
38
- ): string {
39
- const fallback = config.default ?? "main";
40
- if (!config.rules) return fallback;
41
-
42
- for (const rule of config.rules) {
43
- if (ruleMatches(rule, meta)) {
44
- return sanitizeSessionName(expandTemplate(rule.session, meta));
45
- }
46
- }
47
-
48
- return fallback;
49
- }
50
-
51
- const MATCH_KEYS = new Set(["channel", "sender"]);
52
-
53
- function ruleMatches(rule: SessionRule, meta: { channel?: string; sender?: string }): boolean {
54
- for (const [key, pattern] of Object.entries(rule)) {
55
- if (key === "session") continue;
56
- if (!MATCH_KEYS.has(key)) return false;
57
- const value = meta[key as keyof typeof meta] ?? "";
58
- if (!globMatch(pattern, value)) return false;
59
- }
60
- return true;
61
- }
62
-
63
- function expandTemplate(template: string, meta: { channel?: string; sender?: string }): string {
64
- return template
65
- .replace(/\$\{sender\}/g, meta.sender ?? "unknown")
66
- .replace(/\$\{channel\}/g, meta.channel ?? "unknown");
67
- }
68
-
69
- function sanitizeSessionName(name: string): string {
70
- return name.replace(/\0/g, "").replace(/[/\\]/g, "-").replace(/\.\./g, "-").slice(0, 100);
71
- }
@@ -1,4 +0,0 @@
1
- {
2
- "rules": [],
3
- "default": "main"
4
- }
@@ -1,204 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { resolve } from "node:path";
3
- import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
4
- import { query } from "@anthropic-ai/claude-agent-sdk";
5
- import { createPreCompactHook } from "./hooks/pre-compact.js";
6
- import { log, logText, logThinking, logToolUse } from "./logger.js";
7
- import { createMessageChannel } from "./message-channel.js";
8
- import type { Listener, VoluteEvent } from "./types.js";
9
-
10
- type Session = {
11
- name: string;
12
- channel: ReturnType<typeof createMessageChannel>;
13
- listeners: Set<Listener>;
14
- messageIds: (string | undefined)[];
15
- currentMessageId?: string;
16
- currentQuery?: ReturnType<typeof query>;
17
- };
18
-
19
- export function createSessionManager(options: {
20
- systemPrompt: string;
21
- cwd: string;
22
- abortController: AbortController;
23
- model?: string;
24
- sessionsDir: string;
25
- postToolUseHooks: { matcher: string; hooks: HookCallback[] }[];
26
- onTurnDone?: () => void;
27
- compactionMessage?: string;
28
- }) {
29
- const sessions = new Map<string, Session>();
30
- const compactionMessage =
31
- options.compactionMessage ??
32
- "Your conversation is approaching its context limit. Please update today's journal entry to preserve important context before the conversation is compacted.";
33
-
34
- function sessionFilePath(sessionName: string): string {
35
- return resolve(options.sessionsDir, `${sessionName}.json`);
36
- }
37
-
38
- function loadSessionId(sessionName: string): string | undefined {
39
- try {
40
- const data = JSON.parse(readFileSync(sessionFilePath(sessionName), "utf-8"));
41
- return data.sessionId;
42
- } catch {
43
- return undefined;
44
- }
45
- }
46
-
47
- function saveSessionId(sessionName: string, sessionId: string) {
48
- mkdirSync(options.sessionsDir, { recursive: true });
49
- writeFileSync(sessionFilePath(sessionName), JSON.stringify({ sessionId }));
50
- }
51
-
52
- function deleteSessionId(sessionName: string) {
53
- try {
54
- const path = sessionFilePath(sessionName);
55
- if (existsSync(path)) unlinkSync(path);
56
- } catch (err) {
57
- log("agent", `failed to delete session file for "${sessionName}":`, err);
58
- }
59
- }
60
-
61
- function broadcastToSession(session: Session, event: VoluteEvent) {
62
- const tagged =
63
- session.currentMessageId != null ? { ...event, messageId: session.currentMessageId } : event;
64
- for (const listener of session.listeners) {
65
- try {
66
- listener(tagged);
67
- } catch (err) {
68
- log("agent", "listener threw during broadcast:", err);
69
- }
70
- }
71
- }
72
-
73
- function createStream(session: Session, resume?: string) {
74
- const preCompact = createPreCompactHook(() => {
75
- session.messageIds.push(undefined); // internal message, no messageId
76
- session.channel.push({
77
- type: "user",
78
- session_id: "",
79
- message: {
80
- role: "user",
81
- content: [{ type: "text", text: compactionMessage }],
82
- },
83
- parent_tool_use_id: null,
84
- });
85
- });
86
-
87
- return query({
88
- prompt: session.channel.iterable,
89
- options: {
90
- systemPrompt: options.systemPrompt,
91
- permissionMode: "bypassPermissions",
92
- allowDangerouslySkipPermissions: true,
93
- settingSources: ["project"],
94
- cwd: options.cwd,
95
- abortController: options.abortController,
96
- model: options.model,
97
- resume,
98
- hooks: {
99
- PostToolUse: options.postToolUseHooks,
100
- PreCompact: [{ hooks: [preCompact.hook] }],
101
- },
102
- },
103
- });
104
- }
105
-
106
- async function consumeStream(stream: ReturnType<typeof query>, session: Session) {
107
- for await (const msg of stream) {
108
- // At the start of each turn, shift the next messageId
109
- if (session.currentMessageId === undefined) {
110
- session.currentMessageId = session.messageIds.shift();
111
- }
112
- if ("session_id" in msg && msg.session_id) {
113
- if (!session.name.startsWith("new-")) {
114
- saveSessionId(session.name, msg.session_id as string);
115
- }
116
- }
117
- if (msg.type === "assistant") {
118
- for (const b of msg.message.content) {
119
- if (b.type === "thinking" && "thinking" in b && b.thinking) {
120
- logThinking(b.thinking as string);
121
- } else if (b.type === "text") {
122
- const text = (b as { text: string }).text;
123
- logText(text);
124
- broadcastToSession(session, { type: "text", content: text });
125
- } else if (b.type === "tool_use") {
126
- const tb = b as { name: string; input: unknown };
127
- logToolUse(tb.name, tb.input);
128
- broadcastToSession(session, { type: "tool_use", name: tb.name, input: tb.input });
129
- }
130
- }
131
- }
132
- if (msg.type === "result") {
133
- log("agent", `session "${session.name}": turn done`);
134
- broadcastToSession(session, { type: "done" });
135
- session.currentMessageId = undefined;
136
- options.onTurnDone?.();
137
- }
138
- }
139
- }
140
-
141
- function startSession(session: Session, savedSessionId?: string) {
142
- (async () => {
143
- log("agent", `session "${session.name}": stream consumer started`);
144
- try {
145
- const q = createStream(session, savedSessionId);
146
- session.currentQuery = q;
147
- await consumeStream(q, session);
148
- } catch (err) {
149
- if (savedSessionId) {
150
- log("agent", `session "${session.name}": resume failed, starting fresh:`, err);
151
- deleteSessionId(session.name);
152
- try {
153
- const q = createStream(session);
154
- session.currentQuery = q;
155
- await consumeStream(q, session);
156
- } catch (retryErr) {
157
- log("agent", `session "${session.name}": stream consumer error:`, retryErr);
158
- broadcastToSession(session, { type: "done" });
159
- sessions.delete(session.name);
160
- }
161
- } else {
162
- log("agent", `session "${session.name}": stream consumer error:`, err);
163
- broadcastToSession(session, { type: "done" });
164
- sessions.delete(session.name);
165
- }
166
- }
167
- log("agent", `session "${session.name}": stream consumer ended`);
168
- })();
169
- }
170
-
171
- function getOrCreateSession(name: string): Session {
172
- const existing = sessions.get(name);
173
- if (existing) return existing;
174
-
175
- const session: Session = {
176
- name,
177
- channel: createMessageChannel(),
178
- listeners: new Set(),
179
- messageIds: [],
180
- };
181
- sessions.set(name, session);
182
-
183
- const isEphemeral = name.startsWith("new-");
184
- const savedSessionId = isEphemeral ? undefined : loadSessionId(name);
185
- if (savedSessionId) {
186
- log("agent", `session "${name}": resuming ${savedSessionId}`);
187
- } else {
188
- log("agent", `session "${name}": starting fresh`);
189
- }
190
-
191
- startSession(session, savedSessionId);
192
- return session;
193
- }
194
-
195
- function interruptSession(name: string) {
196
- const session = sessions.get(name);
197
- if (session?.currentMessageId !== undefined && session.currentQuery) {
198
- log("agent", `session "${name}": interrupting current turn`);
199
- session.currentQuery.interrupt();
200
- }
201
- }
202
-
203
- return { getOrCreateSession, interruptSession };
204
- }
@@ -1 +0,0 @@
1
- { "rules": [], "default": "main" }
@@ -1,210 +0,0 @@
1
- import { getModel, getModels } from "@mariozechner/pi-ai";
2
- import {
3
- AuthStorage,
4
- createAgentSession,
5
- DefaultResourceLoader,
6
- type ExtensionFactory,
7
- ModelRegistry,
8
- SessionManager,
9
- SettingsManager,
10
- } from "@mariozechner/pi-coding-agent";
11
- import { commitFileChange } from "./auto-commit.js";
12
- import { log, logText, logThinking, logToolResult, logToolUse } from "./logger.js";
13
- import type { Listener, VoluteEvent } from "./types.js";
14
-
15
- type AgentSession = Awaited<ReturnType<typeof createAgentSession>>["session"];
16
-
17
- type PiSession = {
18
- name: string;
19
- agentSession: AgentSession | null;
20
- ready: Promise<void>;
21
- listeners: Set<Listener>;
22
- unsubscribe?: () => void;
23
- messageIds: (string | undefined)[];
24
- currentMessageId?: string;
25
- };
26
-
27
- const DEFAULT_COMPACTION_MESSAGE =
28
- "Your conversation is approaching its context limit. Please update today's journal entry to preserve important context before the conversation is compacted.";
29
-
30
- function resolveModel(modelStr: string) {
31
- const [provider, ...rest] = modelStr.split(":");
32
- const modelId = rest.join(":");
33
-
34
- // Try exact match first, then prefix match against available models
35
- let model = getModel(provider as any, modelId as any);
36
- if (!model) {
37
- const available = getModels(provider as any);
38
- const found = available.find((m) => m.id.startsWith(modelId));
39
- if (found) model = found;
40
- }
41
- if (!model) {
42
- const available = getModels(provider as any);
43
- throw new Error(
44
- `Model not found: ${modelStr}\nAvailable ${provider} models: ${available.map((m) => m.id).join(", ")}`,
45
- );
46
- }
47
- return model;
48
- }
49
-
50
- export function createPiSessionManager(options: {
51
- systemPrompt: string;
52
- cwd: string;
53
- model?: string;
54
- compactionMessage?: string;
55
- }) {
56
- const sessions = new Map<string, PiSession>();
57
- const compactionMessage = options.compactionMessage ?? DEFAULT_COMPACTION_MESSAGE;
58
-
59
- // Shared setup (created once)
60
- const modelStr = options.model || process.env.PI_MODEL || "anthropic:claude-sonnet-4-20250514";
61
- const model = resolveModel(modelStr);
62
- const authStorage = new AuthStorage();
63
- const modelRegistry = new ModelRegistry(authStorage);
64
-
65
- function getOrCreateSession(name: string): PiSession {
66
- const existing = sessions.get(name);
67
- if (existing) return existing;
68
-
69
- const session: PiSession = {
70
- name,
71
- agentSession: null,
72
- ready: Promise.resolve(),
73
- listeners: new Set(),
74
- messageIds: [],
75
- };
76
- sessions.set(name, session);
77
-
78
- session.ready = initSession(session);
79
- return session;
80
- }
81
-
82
- async function initSession(session: PiSession) {
83
- const isEphemeral = session.name.startsWith("new-");
84
-
85
- // Per-session session manager
86
- const sessionManager = isEphemeral
87
- ? SessionManager.inMemory()
88
- : SessionManager.continueRecent(options.cwd, `.volute/pi-sessions/${session.name}`);
89
-
90
- log("agent", `session "${session.name}": ${isEphemeral ? "ephemeral" : "persistent"}`);
91
-
92
- // Per-session pre-compact extension: block once, send compaction message, allow on second call
93
- let compactBlocked = false;
94
- const preCompactExtension: ExtensionFactory = (pi) => {
95
- pi.on("session_before_compact", () => {
96
- if (!compactBlocked) {
97
- compactBlocked = true;
98
- log(
99
- "agent",
100
- `session "${session.name}": blocking compaction — asking agent to update daily log`,
101
- );
102
- session.messageIds.push(undefined); // internal message, no messageId
103
- session.agentSession?.prompt(compactionMessage, { streamingBehavior: "followUp" });
104
- return { cancel: true };
105
- }
106
- compactBlocked = false;
107
- log("agent", `session "${session.name}": allowing compaction`);
108
- });
109
- };
110
-
111
- const settingsManager = SettingsManager.inMemory({
112
- retry: { enabled: true, maxRetries: 3 },
113
- });
114
-
115
- const resourceLoader = new DefaultResourceLoader({
116
- cwd: options.cwd,
117
- settingsManager,
118
- systemPrompt: options.systemPrompt,
119
- extensionFactories: [preCompactExtension],
120
- });
121
- await resourceLoader.reload();
122
-
123
- const { session: agentSession } = await createAgentSession({
124
- cwd: options.cwd,
125
- model,
126
- authStorage,
127
- modelRegistry,
128
- sessionManager,
129
- settingsManager,
130
- resourceLoader,
131
- });
132
-
133
- session.agentSession = agentSession;
134
-
135
- // Per-session event subscription
136
- const toolArgs = new Map<string, any>();
137
-
138
- session.unsubscribe = agentSession.subscribe((event) => {
139
- // At the start of each turn, shift the next messageId
140
- if (session.currentMessageId === undefined) {
141
- session.currentMessageId = session.messageIds.shift();
142
- }
143
-
144
- if (event.type === "message_update") {
145
- const ae = event.assistantMessageEvent;
146
- if (ae.type === "text_delta") {
147
- logText(ae.delta);
148
- broadcast(session, { type: "text", content: ae.delta });
149
- } else if (ae.type === "thinking_delta") {
150
- logThinking(ae.delta);
151
- }
152
- }
153
-
154
- if (event.type === "tool_execution_start") {
155
- toolArgs.set(event.toolCallId, event.args);
156
- logToolUse(event.toolName, event.args);
157
- broadcast(session, { type: "tool_use", name: event.toolName, input: event.args });
158
- }
159
-
160
- if (event.type === "tool_execution_end") {
161
- const output =
162
- typeof event.result === "string" ? event.result : JSON.stringify(event.result);
163
- logToolResult(event.toolName, output, event.isError);
164
- broadcast(session, { type: "tool_result", output, is_error: event.isError });
165
-
166
- // Auto-commit file changes in home/
167
- // pi-coding-agent uses lowercase tool names ("edit", "write") and "path" arg
168
- if ((event.toolName === "edit" || event.toolName === "write") && !event.isError) {
169
- const args = toolArgs.get(event.toolCallId);
170
- const filePath = (args as { path?: string })?.path;
171
- if (filePath) {
172
- commitFileChange(filePath, options.cwd);
173
- }
174
- }
175
- toolArgs.delete(event.toolCallId);
176
- }
177
-
178
- if (event.type === "agent_end") {
179
- log("agent", `session "${session.name}": turn done`);
180
- broadcast(session, { type: "done" });
181
- session.currentMessageId = undefined;
182
- }
183
- });
184
-
185
- log("agent", `session "${session.name}": ready`);
186
- }
187
-
188
- function broadcast(session: PiSession, event: VoluteEvent) {
189
- const tagged =
190
- session.currentMessageId != null ? { ...event, messageId: session.currentMessageId } : event;
191
- for (const listener of session.listeners) {
192
- try {
193
- listener(tagged);
194
- } catch (err) {
195
- log("agent", "listener threw during broadcast:", err);
196
- }
197
- }
198
- }
199
-
200
- function interruptSession(name: string) {
201
- const session = sessions.get(name);
202
- if (session?.currentMessageId !== undefined) {
203
- log("agent", `session "${name}": interrupting current turn`);
204
- broadcast(session, { type: "done" });
205
- session.currentMessageId = undefined;
206
- }
207
- }
208
-
209
- return { getOrCreateSession, interruptSession };
210
- }
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- resolveVoluteBin
4
- } from "./chunk-5SKQ6J7T.js";
5
2
  import {
6
3
  parseArgs
7
4
  } from "./chunk-D424ZQGI.js";
5
+ import {
6
+ resolveVoluteBin
7
+ } from "./chunk-5SKQ6J7T.js";
8
8
  import "./chunk-K3NQKI34.js";
9
9
 
10
10
  // src/commands/service.ts