@townco/agent 0.1.138 → 0.1.140

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.
@@ -1736,55 +1736,89 @@ export class AgentAcpAdapter {
1736
1736
  finalTokens: compactionMeta.finalTokens,
1737
1737
  hasOriginalContent: !!compactionMeta.originalContent,
1738
1738
  });
1739
+ // Save pre-extracted sources before removing metadata
1740
+ const preExtractedSources = compactionMeta.preExtractedSources;
1739
1741
  // Remove the metadata from rawOutput to keep it clean
1740
1742
  const { _compactionMeta: _, ...cleanOutput } = rawOutput;
1741
1743
  rawOutput = cleanOutput;
1742
- }
1743
- if (rawOutput && !this.noSession) {
1744
- // IMPORTANT: Extract citations BEFORE hook execution
1745
- // Hooks may compact/modify rawOutput, losing citation data
1746
- // We extract from the original output to preserve citation sources
1747
- const rawOutputForCitations = rawOutput;
1748
- logger.debug("Extracting citations BEFORE compaction hooks", {
1749
- toolCallId: outputMsg.toolCallId,
1750
- toolName: toolCallBlock.title,
1751
- rawOutputType: typeof rawOutputForCitations,
1752
- });
1753
- // Handle subagent tool outputs first
1754
- const subagentResult = this.extractSubagentSources(rawOutput, outputMsg.toolCallId);
1755
- if (subagentResult && subagentResult.sources.length > 0) {
1756
- session.sources.push(...subagentResult.sources);
1744
+ // If we have pre-extracted sources from the runner (before compaction),
1745
+ // use them directly instead of trying to extract from compacted output
1746
+ if (preExtractedSources &&
1747
+ preExtractedSources.length > 0 &&
1748
+ !this.noSession) {
1749
+ const sourcesForSession = preExtractedSources.map((s) => ({
1750
+ id: s.id,
1751
+ url: s.url,
1752
+ title: s.title,
1753
+ toolCallId: outputMsg.toolCallId,
1754
+ ...(s.snippet && { snippet: s.snippet }),
1755
+ ...(s.favicon && { favicon: s.favicon }),
1756
+ ...(s.sourceName && { sourceName: s.sourceName }),
1757
+ }));
1758
+ session.sources.push(...sourcesForSession);
1757
1759
  this.connection.sessionUpdate({
1758
1760
  sessionId: params.sessionId,
1759
1761
  update: {
1760
1762
  sessionUpdate: "sources",
1761
- sources: subagentResult.sources,
1763
+ sources: sourcesForSession,
1762
1764
  },
1763
1765
  });
1764
- logger.info("Extracted citation sources from subagent (pre-compaction)", {
1766
+ logger.info("Using pre-extracted sources from runner (preserved before compaction)", {
1765
1767
  toolCallId: outputMsg.toolCallId,
1766
- sourcesCount: subagentResult.sources.length,
1768
+ toolName: toolCallBlock.title,
1769
+ sourcesCount: sourcesForSession.length,
1767
1770
  });
1768
1771
  }
1769
- // Extract from regular tool outputs (WebSearch, WebFetch, MCP tools)
1770
- const subagentHadSources = subagentResult && subagentResult.sources.length > 0;
1771
- if (!subagentHadSources) {
1772
- const extractedSources = this.extractSourcesFromToolOutput(toolCallBlock.title, rawOutput, outputMsg.toolCallId, session);
1773
- if (extractedSources.length > 0) {
1774
- session.sources.push(...extractedSources);
1772
+ }
1773
+ if (rawOutput && !this.noSession) {
1774
+ // Check if we already have sources from pre-extraction
1775
+ const alreadyHasSources = session.sources.some((s) => s.toolCallId === outputMsg.toolCallId);
1776
+ if (!alreadyHasSources) {
1777
+ // IMPORTANT: Extract citations BEFORE hook execution
1778
+ // Hooks may compact/modify rawOutput, losing citation data
1779
+ // We extract from the original output to preserve citation sources
1780
+ const rawOutputForCitations = rawOutput;
1781
+ logger.debug("Extracting citations BEFORE compaction hooks", {
1782
+ toolCallId: outputMsg.toolCallId,
1783
+ toolName: toolCallBlock.title,
1784
+ rawOutputType: typeof rawOutputForCitations,
1785
+ });
1786
+ // Handle subagent tool outputs first
1787
+ const subagentResult = this.extractSubagentSources(rawOutput, outputMsg.toolCallId);
1788
+ if (subagentResult && subagentResult.sources.length > 0) {
1789
+ session.sources.push(...subagentResult.sources);
1775
1790
  this.connection.sessionUpdate({
1776
1791
  sessionId: params.sessionId,
1777
1792
  update: {
1778
1793
  sessionUpdate: "sources",
1779
- sources: extractedSources,
1794
+ sources: subagentResult.sources,
1780
1795
  },
1781
1796
  });
1782
- logger.info("Extracted citation sources from tool output (pre-compaction)", {
1797
+ logger.info("Extracted citation sources from subagent (pre-compaction)", {
1783
1798
  toolCallId: outputMsg.toolCallId,
1784
- toolName: toolCallBlock.title,
1785
- sourcesCount: extractedSources.length,
1799
+ sourcesCount: subagentResult.sources.length,
1786
1800
  });
1787
1801
  }
1802
+ // Extract from regular tool outputs (WebSearch, WebFetch, MCP tools)
1803
+ const subagentHadSources = subagentResult && subagentResult.sources.length > 0;
1804
+ if (!subagentHadSources) {
1805
+ const extractedSources = this.extractSourcesFromToolOutput(toolCallBlock.title, rawOutput, outputMsg.toolCallId, session);
1806
+ if (extractedSources.length > 0) {
1807
+ session.sources.push(...extractedSources);
1808
+ this.connection.sessionUpdate({
1809
+ sessionId: params.sessionId,
1810
+ update: {
1811
+ sessionUpdate: "sources",
1812
+ sources: extractedSources,
1813
+ },
1814
+ });
1815
+ logger.info("Extracted citation sources from tool output (pre-compaction)", {
1816
+ toolCallId: outputMsg.toolCallId,
1817
+ toolName: toolCallBlock.title,
1818
+ sourcesCount: extractedSources.length,
1819
+ });
1820
+ }
1821
+ }
1788
1822
  }
1789
1823
  // Execute tool_response hooks if configured
1790
1824
  const hooks = this.agent.definition.hooks ?? [];
@@ -1,6 +1,8 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
+ import { createLogger } from "../logger.js";
5
+ const logger = createLogger("session-storage");
4
6
  /**
5
7
  * Zod schema for validating session files
6
8
  */
@@ -273,12 +275,8 @@ export class SessionStorage {
273
275
  try {
274
276
  // Write to temp file
275
277
  writeFileSync(tempPath, JSON.stringify(session, null, 2), "utf-8");
276
- // Atomic rename
277
- if (existsSync(sessionPath)) {
278
- unlinkSync(sessionPath);
279
- }
280
- writeFileSync(sessionPath, readFileSync(tempPath, "utf-8"), "utf-8");
281
- unlinkSync(tempPath);
278
+ // Atomic rename - renameSync is atomic on POSIX systems
279
+ renameSync(tempPath, sessionPath);
282
280
  }
283
281
  catch (error) {
284
282
  // Clean up temp file on error
@@ -424,8 +422,12 @@ export class SessionStorage {
424
422
  sessions.push(entry);
425
423
  }
426
424
  }
427
- catch {
428
- // Skip invalid sessions
425
+ catch (error) {
426
+ // Log and skip invalid sessions so they don't break the session list
427
+ logger.warn("Failed to load session, skipping from list", {
428
+ sessionId,
429
+ error: error instanceof Error ? error.message : String(error),
430
+ });
429
431
  }
430
432
  }
431
433
  // Sort by updatedAt, most recent first
@@ -506,11 +508,8 @@ export class SessionStorage {
506
508
  const tempPath = `${sessionPath}.tmp`;
507
509
  try {
508
510
  writeFileSync(tempPath, JSON.stringify(updatedSession, null, 2), "utf-8");
509
- if (existsSync(sessionPath)) {
510
- unlinkSync(sessionPath);
511
- }
512
- writeFileSync(sessionPath, readFileSync(tempPath, "utf-8"), "utf-8");
513
- unlinkSync(tempPath);
511
+ // Atomic rename - renameSync is atomic on POSIX systems
512
+ renameSync(tempPath, sessionPath);
514
513
  }
515
514
  catch (error) {
516
515
  if (existsSync(tempPath)) {
@@ -39,6 +39,171 @@ getWeather.icon = "Cloud";
39
39
  function isPlainRecord(v) {
40
40
  return !!v && typeof v === "object" && !Array.isArray(v);
41
41
  }
42
+ /**
43
+ * Extract source name from URL domain
44
+ */
45
+ function getSourceNameFromUrl(url) {
46
+ try {
47
+ const domain = new URL(url).hostname.replace(/^www\./, "");
48
+ return domain;
49
+ }
50
+ catch {
51
+ return "Web";
52
+ }
53
+ }
54
+ /**
55
+ * Extract favicon URL from domain
56
+ */
57
+ function getFaviconFromUrl(url) {
58
+ try {
59
+ const domain = new URL(url).hostname;
60
+ return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ /**
67
+ * Extract citation sources from raw tool output BEFORE compaction.
68
+ * This preserves URLs that may be removed during LLM compaction.
69
+ */
70
+ function extractSourcesBeforeCompaction(toolName, rawOutput) {
71
+ const sources = [];
72
+ let sourceCounter = 0;
73
+ // Parse content if it's a JSON string (common for MCP tools)
74
+ let actualOutput = rawOutput;
75
+ if (typeof rawOutput.content === "string") {
76
+ try {
77
+ const parsed = JSON.parse(rawOutput.content);
78
+ if (typeof parsed === "object" && parsed !== null) {
79
+ actualOutput = parsed;
80
+ }
81
+ }
82
+ catch {
83
+ // Not valid JSON, use rawOutput as-is
84
+ }
85
+ }
86
+ // Handle WebSearch (Exa) results
87
+ if (toolName === "WebSearch" ||
88
+ toolName === "web_search" ||
89
+ toolName === "town_web_search") {
90
+ const formattedResults = actualOutput.formattedForCitation;
91
+ if (Array.isArray(formattedResults)) {
92
+ for (const result of formattedResults) {
93
+ if (result &&
94
+ typeof result === "object" &&
95
+ "url" in result &&
96
+ typeof result.url === "string") {
97
+ sourceCounter++;
98
+ const url = result.url;
99
+ sources.push({
100
+ id: typeof result.citationId === "number"
101
+ ? String(result.citationId)
102
+ : String(sourceCounter),
103
+ url,
104
+ title: typeof result.title === "string" ? result.title : "Untitled",
105
+ snippet: typeof result.text === "string"
106
+ ? result.text.slice(0, 200)
107
+ : undefined,
108
+ favicon: getFaviconFromUrl(url),
109
+ sourceName: getSourceNameFromUrl(url),
110
+ });
111
+ }
112
+ }
113
+ }
114
+ else {
115
+ // Fallback to raw results
116
+ const results = actualOutput.results;
117
+ if (Array.isArray(results)) {
118
+ for (const result of results) {
119
+ if (result &&
120
+ typeof result === "object" &&
121
+ "url" in result &&
122
+ typeof result.url === "string") {
123
+ sourceCounter++;
124
+ const url = result.url;
125
+ sources.push({
126
+ id: String(sourceCounter),
127
+ url,
128
+ title: typeof result.title === "string" ? result.title : "Untitled",
129
+ snippet: typeof result.text === "string"
130
+ ? result.text.slice(0, 200)
131
+ : undefined,
132
+ favicon: getFaviconFromUrl(url),
133
+ sourceName: getSourceNameFromUrl(url),
134
+ });
135
+ }
136
+ }
137
+ }
138
+ }
139
+ }
140
+ // Handle library/document retrieval tools
141
+ const isLibraryTool = toolName.startsWith("library__") ||
142
+ toolName.includes("get_document") ||
143
+ toolName.includes("retrieve_document") ||
144
+ toolName.includes("bibliotecha");
145
+ if (isLibraryTool) {
146
+ const extractDocSource = (doc) => {
147
+ const docUrl = typeof doc.document_url === "string"
148
+ ? doc.document_url
149
+ : typeof doc.url === "string"
150
+ ? doc.url
151
+ : typeof doc.source_url === "string"
152
+ ? doc.source_url
153
+ : null;
154
+ const docTitle = typeof doc.title === "string" ? doc.title : null;
155
+ const docId = typeof doc.document_id === "number"
156
+ ? String(doc.document_id)
157
+ : typeof doc.document_id === "string"
158
+ ? doc.document_id
159
+ : null;
160
+ if (!docUrl && !docTitle)
161
+ return null;
162
+ sourceCounter++;
163
+ // Extract source name from source field or URL
164
+ let sourceName;
165
+ if (typeof doc.source === "string") {
166
+ sourceName = doc.source
167
+ .split("_")
168
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
169
+ .join(" ");
170
+ }
171
+ else if (docUrl) {
172
+ sourceName = getSourceNameFromUrl(docUrl);
173
+ }
174
+ return {
175
+ id: docId || String(sourceCounter),
176
+ url: docUrl || "",
177
+ title: docTitle || docUrl || "Document",
178
+ snippet: typeof doc.summary === "string"
179
+ ? doc.summary.slice(0, 200)
180
+ : typeof doc.snippet === "string"
181
+ ? doc.snippet.slice(0, 200)
182
+ : undefined,
183
+ favicon: docUrl ? getFaviconFromUrl(docUrl) : undefined,
184
+ sourceName,
185
+ };
186
+ };
187
+ // Check for results array (library__search_keyword, library__semantic_search)
188
+ const results = actualOutput.results ?? actualOutput.documents;
189
+ if (Array.isArray(results)) {
190
+ for (const result of results) {
191
+ if (result && typeof result === "object") {
192
+ const source = extractDocSource(result);
193
+ if (source)
194
+ sources.push(source);
195
+ }
196
+ }
197
+ }
198
+ else {
199
+ // Handle single document
200
+ const source = extractDocSource(actualOutput);
201
+ if (source)
202
+ sources.push(source);
203
+ }
204
+ }
205
+ return sources;
206
+ }
42
207
  function stableStringify(value) {
43
208
  const seen = new WeakSet();
44
209
  const _stringify = (v) => {
@@ -589,6 +754,9 @@ export class LangchainAgent {
589
754
  ? { content: result }
590
755
  : { content: JSON.stringify(result) };
591
756
  const outputTokens = await countToolResultTokens(rawOutput);
757
+ // Extract citation sources BEFORE compaction to preserve URLs
758
+ // Compaction LLM may remove URLs as "unnecessary" during summarization
759
+ const preExtractedSources = extractSourcesBeforeCompaction(originalTool.name, rawOutput);
592
760
  // Include current prompt as the last user message for better context.
593
761
  const nowIso = new Date().toISOString();
594
762
  const promptBlocksForHooks = req.prompt.map((block) => {
@@ -673,8 +841,20 @@ export class LangchainAgent {
673
841
  if (typeof originalContentPath === "string") {
674
842
  meta.originalContentPath = originalContentPath;
675
843
  }
844
+ // Include pre-extracted sources (extracted before compaction)
845
+ if (preExtractedSources.length > 0) {
846
+ meta.preExtractedSources = preExtractedSources;
847
+ }
676
848
  inflightCompactionMetaByToolCallId.set(toolCallId, meta);
677
849
  }
850
+ else if (preExtractedSources.length > 0) {
851
+ // Even without compaction, pass pre-extracted sources to adapter
852
+ // This ensures sources are always available for citation extraction
853
+ inflightCompactionMetaByToolCallId.set(toolCallId, {
854
+ action: "compacted", // Minimal action to indicate metadata presence
855
+ preExtractedSources,
856
+ });
857
+ }
678
858
  // Return compacted output to LangChain (model-visible), without metadata.
679
859
  if (typeof result === "string") {
680
860
  if (typeof finalOutput.content === "string") {
@@ -30,6 +30,11 @@ export async function createModelFromString(modelString) {
30
30
  if (!shedAuth) {
31
31
  throw new Error("Not logged in. Run 'town login' or set SHED_API_KEY.");
32
32
  }
33
+ console.log("[model-factory] town- model, sending to shed:", {
34
+ shedUrl: shedAuth.shedUrl,
35
+ tokenPrefix: shedAuth.accessToken?.slice(0, 20),
36
+ tokenLength: shedAuth.accessToken?.length,
37
+ });
33
38
  return new ChatAnthropic({
34
39
  model: actualModel,
35
40
  anthropicApiUrl: `${shedAuth.shedUrl}/api/anthropic`,