This commit is contained in:
leanmachine1209-ui 2026-06-27 06:58:46 +00:00 committed by GitHub
commit 7ff314ff96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 62636 additions and 0 deletions

View file

@ -0,0 +1,8 @@
{
"presets": [
["@babel/preset-env", {
"targets": { "chrome": "109" },
"modules": false
}]
]
}

View file

@ -0,0 +1,87 @@
---
name: run-agrifine-extension
description: Run, build, launch, screenshot, or drive the Agrifine browser extension UI. Use when asked to start, test, verify, or take a screenshot of the extension sidebar or any of its tabs (reading list, data ingest, field profiles, dashboard, AgriAgent).
---
# run-agrifine-extension
Agrifine is a Manifest V3 Chrome extension with a persistent sidebar panel. The sidebar (`dist/sidebar.html`) is driven headlessly via Playwright using the pre-installed Chromium at `/opt/pw-browsers`. A `chrome.*` API stub lets the page render without a real extension context.
All paths below are relative to `agrifine-extension/` (the unit root).
## Prerequisites
Node.js 18+ and Playwright are already in `node_modules` (added as devDependency). Set this env var for every command:
```bash
export PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers
```
## Build
```bash
npm run build
# → dist/ produced, webpack compiled successfully
```
## Run — agent path (driver)
Driver: `.claude/skills/run-agrifine-extension/driver.mjs`
Screenshots land in: `screenshots/`
**Single command:**
```bash
PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers node .claude/skills/run-agrifine-extension/driver.mjs "ss sidebar_initial"
# → screenshots/sidebar_initial.png
```
**Interactive REPL:**
```bash
PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers node .claude/skills/run-agrifine-extension/driver.mjs
# agrifine> ss reading-tab
# agrifine> tab agent
# agrifine> ss agent-tab
# agrifine> eval document.querySelector('#main-content').innerHTML.slice(0,200)
# agrifine> quit
```
**Available REPL commands:**
| Command | Effect |
|---|---|
| `ss [name]` | Screenshot → `screenshots/<name>.png` |
| `tab <name>` | Switch tab: `reading`, `ingest`, `fields`, `dashboard`, `carbon`, `agent` |
| `click <selector>` | Click a CSS selector |
| `type <selector> <text>` | Fill an input |
| `eval <js>` | Evaluate JS in page context, print result |
| `quit` | Exit |
## Verified flows (run in this container)
```bash
# Initial sidebar — Reading List tab
PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers node .claude/skills/run-agrifine-extension/driver.mjs "ss sidebar_initial"
# All 5 tabs
PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers node -e "..." # (see driver source)
```
Screenshots confirmed: green header, bottom tab bar with 6 tabs (Reading, Ingest, Fields, Dashboard, Carbon, Agent), AgriAgent chat UI with suggested prompts visible.
## Gotchas
- **`chrome.*` APIs are stubbed** — storage reads return null, `sendMessage` returns an error object. The sidebar renders and navigates correctly; AI calls fail gracefully with "No API key set."
- **Extension loaded from `dist/`** — always `npm run build` first. The driver checks for `dist/manifest.json` and exits with a clear error if missing.
- **`PLAYWRIGHT_BROWSERS_PATH` must be set** — without it, Playwright tries to download browsers and fails. Always export it before running the driver.
- **PersistentContext required** — Chrome extensions only load in `launchPersistentContext`, not `launch`. The profile dir is passed as `''` (temp, cleaned up on exit).
- **Tabs are data-attribute driven** — selectors are `[data-tab="reading-list"]` etc. The driver maps short names (`reading`, `agent`) to full attribute values.
## Troubleshooting
| Error | Fix |
|---|---|
| `Cannot find package 'playwright'` | `npm install` inside `agrifine-extension/` |
| `dist/ not found` | `npm run build` |
| `Error: dist/ not found` with correct path | Check `UNIT_ROOT` in driver — must resolve to `agrifine-extension/`, 3 levels up from skill dir |
| Page blank / `#main-content` timeout | Chrome stub missing — ensure `addInitScript` runs before `goto` |
| `ERR_FILE_NOT_FOUND` for sidebar.html | Build produced it at wrong path — check `webpack.config.js` CopyPlugin target |

View file

@ -0,0 +1,162 @@
#!/usr/bin/env node
/**
* Agrifine Extension driver
* Launches Chrome with the unpacked extension loaded, opens the sidebar
* page directly, and exposes a simple REPL for agent interaction.
*
* Usage:
* node driver.mjs [command]
*
* Commands (interactive REPL if none given):
* ss [file] Take screenshot screenshots/<file>.png
* click <selector> Click element
* tab <name> Click tab by label (reading|ingest|fields|dashboard|carbon|agent)
* type <sel> <text> Type into element
* eval <js> Evaluate JS in page, print result
* quit Exit
*/
import { chromium } from 'playwright';
import { createInterface } from 'readline';
import { mkdirSync, existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dir = dirname(fileURLToPath(import.meta.url));
// Skill lives at .claude/skills/run-agrifine-extension/
// Unit root (agrifine-extension/) is 3 levels up
const UNIT_ROOT = resolve(__dir, '..', '..', '..');
const DIST = resolve(UNIT_ROOT, 'dist');
const SCREENSHOTS = resolve(UNIT_ROOT, 'screenshots');
const CHROMIUM = process.env.PLAYWRIGHT_BROWSERS_PATH
? `${process.env.PLAYWRIGHT_BROWSERS_PATH}/chromium-1194/chrome-linux/chrome`
: '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
mkdirSync(SCREENSHOTS, { recursive: true });
async function main() {
if (!existsSync(DIST + '/manifest.json')) {
console.error('ERROR: dist/ not found. Run: npm run build');
process.exit(1);
}
console.log('Launching Chrome with Agrifine extension…');
// Chrome requires a persistent context to load extensions
const context = await chromium.launchPersistentContext('', {
executablePath: CHROMIUM,
headless: true,
args: [
`--disable-extensions-except=${DIST}`,
`--load-extension=${DIST}`,
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
],
});
// Open the sidebar HTML directly — works for visual/UI testing
// (chrome.* APIs are mocked via the stub below)
const page = await context.newPage();
// Stub chrome.* APIs so the page renders without a real extension context
await page.addInitScript(() => {
const store = {};
window.chrome = {
storage: {
local: {
get: (k, cb) => cb({ [k]: null }),
set: (_o, cb) => cb && cb(),
},
session: {
get: (k, cb) => cb({ [k]: null }),
set: (_o, cb) => cb && cb(),
},
},
runtime: {
sendMessage: (_msg, cb) => cb && cb({ error: 'No background in test mode' }),
connect: () => ({ onDisconnect: { addListener: () => {} } }),
lastError: null,
},
tabs: {
query: (_q, cb) => cb([{ id: 1, url: 'https://example.com', title: 'Test Page' }]),
sendMessage: (_id, _msg, cb) => cb && cb({ text: 'test page content', title: 'Test' }),
},
sidePanel: { setPanelBehavior: () => Promise.resolve() },
};
});
await page.goto(`file://${DIST}/sidebar.html`);
await page.waitForSelector('#main-content', { timeout: 5000 });
console.log('Extension sidebar loaded.');
// Single command mode
const args = process.argv.slice(2);
if (args.length > 0) {
await runCommand(page, args.join(' '));
await context.close();
return;
}
// Interactive REPL
console.log('REPL ready. Commands: ss [file] | click <sel> | tab <name> | type <sel> <text> | eval <js> | quit');
const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: 'agrifine> ' });
rl.prompt();
rl.on('line', async (line) => {
const cmd = line.trim();
if (!cmd) { rl.prompt(); return; }
if (cmd === 'quit' || cmd === 'exit') { await context.close(); process.exit(0); }
await runCommand(page, cmd);
rl.prompt();
});
rl.on('close', async () => { await context.close(); });
}
async function runCommand(page, cmd) {
const [verb, ...rest] = cmd.split(/\s+/);
try {
if (verb === 'ss') {
const name = rest[0] || `screenshot_${Date.now()}`;
const file = `${SCREENSHOTS}/${name.endsWith('.png') ? name : name + '.png'}`;
await page.screenshot({ path: file, fullPage: false });
console.log(`Screenshot: ${file}`);
} else if (verb === 'tab') {
const label = rest[0]?.toLowerCase();
const TAB_MAP = {
reading: '[data-tab="reading-list"]',
ingest: '[data-tab="data-ingest"]',
fields: '[data-tab="field-profile"]',
dashboard: '[data-tab="dashboard"]',
carbon: '[data-tab="carbon-estimator"]',
agent: '[data-tab="ag-refine"]',
};
const sel = TAB_MAP[label] ?? `[data-tab="${label}"]`;
await page.click(sel);
await page.waitForTimeout(300);
console.log(`Clicked tab: ${label}`);
} else if (verb === 'click') {
await page.click(rest.join(' '));
await page.waitForTimeout(200);
console.log('Clicked.');
} else if (verb === 'type') {
const [sel, ...words] = rest;
await page.fill(sel, words.join(' '));
console.log('Typed.');
} else if (verb === 'eval') {
const result = await page.evaluate(rest.join(' '));
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`Unknown command: ${verb}. Try: ss | tab | click | type | eval | quit`);
}
} catch (err) {
console.error(`Error: ${err.message}`);
}
}
main().catch((err) => { console.error(err); process.exit(1); });

2
agrifine-extension/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
*.map

1666
agrifine-extension/dist/background.js vendored Normal file

File diff suppressed because it is too large Load diff

35
agrifine-extension/dist/content.js vendored Normal file
View file

@ -0,0 +1,35 @@
/******/ (() => { // webpackBootstrap
/*!******************************!*\
!*** ./src/content/index.js ***!
\******************************/
// Content script — minimal surface. Relays page metadata to background on request.
chrome.runtime.onMessage.addListener(function (message, _sender, sendResponse) {
if (message.type === 'GET_PAGE_INFO') {
sendResponse({
url: window.location.href,
title: document.title,
text: extractMainText()
});
}
});
function extractMainText() {
var _document$body$innerT, _document$body;
var selectors = ['article', 'main', '[role="main"]', '.content', '#content', 'body'];
for (var _i = 0, _selectors = selectors; _i < _selectors.length; _i++) {
var sel = _selectors[_i];
var el = document.querySelector(sel);
if (el) {
// Strip scripts and styles, return first 8000 chars
var clone = el.cloneNode(true);
clone.querySelectorAll('script,style,nav,header,footer,aside').forEach(function (n) {
return n.remove();
});
return clone.innerText.trim().slice(0, 8000);
}
}
return (_document$body$innerT = (_document$body = document.body) === null || _document$body === void 0 || (_document$body = _document$body.innerText) === null || _document$body === void 0 ? void 0 : _document$body.slice(0, 8000)) !== null && _document$body$innerT !== void 0 ? _document$body$innerT : '';
}
/******/ })()
;
//# sourceMappingURL=content.js.map

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

BIN
agrifine-extension/dist/icons/icon16.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

BIN
agrifine-extension/dist/icons/icon32.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

BIN
agrifine-extension/dist/icons/icon48.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

45
agrifine-extension/dist/manifest.json vendored Normal file
View file

@ -0,0 +1,45 @@
{
"manifest_version": 3,
"name": "Agrifine",
"version": "0.1.0",
"description": "Farm data dashboard — reading list, data ingestion, field profiles, and AI-powered insights.",
"permissions": [
"storage",
"sidePanel",
"activeTab",
"scripting",
"tabs"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"side_panel": {
"default_path": "sidebar.html"
},
"action": {
"default_title": "Open Agrifine",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}

21
agrifine-extension/dist/pdf.worker.js vendored Normal file

File diff suppressed because one or more lines are too long

1270
agrifine-extension/dist/sidebar.css vendored Normal file

File diff suppressed because it is too large Load diff

129
agrifine-extension/dist/sidebar.html vendored Normal file
View file

@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AG Refine</title>
<link rel="stylesheet" href="sidebar.css" />
</head>
<body class="h-screen flex flex-col overflow-hidden font-sans" style="background:#0f1621;color:#e2e8f0;">
<!-- Header — matches AG Refine branding -->
<header class="flex items-center justify-between px-4 py-3 flex-shrink-0" style="background:#131c2b;border-bottom:1px solid #1e2d40;">
<div class="flex items-start gap-2.5">
<div class="leading-none">
<div class="text-lg font-black text-white tracking-tight leading-none">AG</div>
<div class="text-[10px] font-bold text-white tracking-widest leading-none mt-0.5">REFINE</div>
<div class="text-[7px] font-semibold tracking-widest leading-none mt-1" style="color:#3d4f66;">FIELD INTELLIGENCE</div>
</div>
</div>
<button id="btn-settings" class="transition" style="color:#3d4f66;" onmouseenter="this.style.color='#e2e8f0'" onmouseleave="this.style.color='#3d4f66'" title="Settings">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</header>
<!-- Settings panel -->
<div id="settings-panel" class="hidden flex-shrink-0 px-4 py-3" style="background:#131c2b;border-bottom:1px solid #1e2d40;">
<label class="block text-[10px] uppercase tracking-widest font-semibold mb-2" style="color:#3d4f66;">Anthropic API Key</label>
<div class="flex gap-2">
<input id="api-key-input" type="password" placeholder="sk-ant-..."
class="ag-input flex-1" />
<button id="btn-save-key"
class="text-xs bg-agri-600 hover:bg-agri-700 text-white px-3 py-1.5 rounded-lg transition font-medium">Save</button>
</div>
<div class="flex items-center justify-between mt-2">
<label class="flex items-center gap-1.5 cursor-pointer select-none">
<input id="api-key-remember" type="checkbox" class="accent-agri-500 w-3 h-3" />
<span class="text-xs" style="color:#3d4f66;">Remember across sessions</span>
</label>
<button id="btn-forget-key" class="text-xs hover:text-red-400 transition hidden" style="color:#3d4f66;">Forget saved key</button>
</div>
<p id="api-key-status" class="text-xs mt-1" style="color:#3d4f66;"></p>
<label class="block text-[10px] uppercase tracking-widest font-semibold mb-2 mt-3" style="color:#3d4f66;">AG-Refine App URL</label>
<div class="flex gap-2">
<input id="agrefine-url-input" type="url" placeholder="http://localhost:3000"
class="ag-input flex-1" />
<button id="btn-save-agrefine-url"
class="text-xs bg-agri-600 hover:bg-agri-700 text-white px-3 py-1.5 rounded-lg transition font-medium">Save</button>
</div>
<p id="agrefine-url-status" class="text-xs mt-1" style="color:#3d4f66;">Used to sync fields and outputs from your AG-Refine app.</p>
</div>
<!-- Main content -->
<main id="main-content" class="flex-1 overflow-y-auto" style="background:#0f1621;"></main>
<!-- Bottom tab bar -->
<nav class="flex-shrink-0" style="background:#131c2b;border-top:1px solid #1e2d40;">
<div class="flex">
<button data-tab="reading-list"
class="tab-btn flex-1 flex flex-col items-center pt-2 pb-1.5 px-1 text-[9px] font-semibold tracking-wide transition active-tab" style="color:#3d4f66;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<span>INTEL</span>
<div class="tab-dot w-1 h-1 rounded-full bg-agri-400 mt-0.5 opacity-0"></div>
</button>
<button data-tab="data-ingest"
class="tab-btn flex-1 flex flex-col items-center pt-2 pb-1.5 px-1 text-[9px] font-semibold tracking-wide transition" style="color:#3d4f66;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<span>INGEST</span>
<div class="tab-dot w-1 h-1 rounded-full bg-agri-400 mt-0.5 opacity-0"></div>
</button>
<button data-tab="field-profile"
class="tab-btn flex-1 flex flex-col items-center pt-2 pb-1.5 px-1 text-[9px] font-semibold tracking-wide transition" style="color:#3d4f66;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>FIELDS</span>
<div class="tab-dot w-1 h-1 rounded-full bg-agri-400 mt-0.5 opacity-0"></div>
</button>
<button data-tab="dashboard"
class="tab-btn flex-1 flex flex-col items-center pt-2 pb-1.5 px-1 text-[9px] font-semibold tracking-wide transition" style="color:#3d4f66;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span>DATA</span>
<div class="tab-dot w-1 h-1 rounded-full bg-agri-400 mt-0.5 opacity-0"></div>
</button>
<button data-tab="carbon-estimator"
class="tab-btn flex-1 flex flex-col items-center pt-2 pb-1.5 px-1 text-[9px] font-semibold tracking-wide transition" style="color:#3d4f66;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
<span>CARBON</span>
<div class="tab-dot w-1 h-1 rounded-full bg-agri-400 mt-0.5 opacity-0"></div>
</button>
<button data-tab="ag-refine"
class="tab-btn flex-1 flex flex-col items-center pt-2 pb-1.5 px-1 text-[9px] font-semibold tracking-wide transition" style="color:#3d4f66;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span>AGENT</span>
<div class="tab-dot w-1 h-1 rounded-full bg-agri-400 mt-0.5 opacity-0"></div>
</button>
</div>
</nav>
<script src="sidebar.js"></script>
</body>
</html>

4387
agrifine-extension/dist/sidebar.js vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,45 @@
{
"manifest_version": 3,
"name": "Agrifine",
"version": "0.1.0",
"description": "Farm data dashboard — reading list, data ingestion, field profiles, and AI-powered insights.",
"permissions": [
"storage",
"sidePanel",
"activeTab",
"scripting",
"tabs"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"side_panel": {
"default_path": "sidebar.html"
},
"action": {
"default_title": "Open Agrifine",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}

5330
agrifine-extension/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
{
"name": "agrifine-extension",
"version": "0.1.0",
"description": "Browser extension farm data dashboard",
"private": true,
"scripts": {
"build": "webpack --config webpack/webpack.config.js",
"watch": "webpack --config webpack/webpack.config.js --watch",
"build:prod": "NODE_ENV=production webpack --config webpack/webpack.config.js"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0",
"autoprefixer": "^10.4.19",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^7.1.2",
"mini-css-extract-plugin": "^2.9.0",
"playwright": "^1.61.1",
"postcss": "^8.4.38",
"postcss-loader": "^8.1.1",
"tailwindcss": "^3.4.3",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"papaparse": "^5.4.1",
"pdfjs-dist": "^4.2.67",
"xlsx": "^0.18.5"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -0,0 +1,171 @@
import { sessionGet, KEYS, buildContextBundle } from '../utils/storage.js';
import { TOOL_DEFINITIONS, executeTool } from './tools.js';
const MODEL = 'claude-sonnet-4-6';
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
const MAX_ITERATIONS = 10;
/**
* AgrifineAgent agentic loop with tool use.
*
* Runs entirely in the sidebar context via the background worker proxy.
* Each call to run() streams back events via an onEvent callback so the
* UI can update incrementally as the agent thinks and calls tools.
*/
export class AgrifineAgent {
constructor({ onEvent }) {
this.onEvent = onEvent; // ({ type, data }) => void
}
async run(userMessage) {
const apiKey = await sessionGet(KEYS.API_KEY);
if (!apiKey) {
this.onEvent({ type: 'error', data: 'No API key set. Open ⚙ Settings to add your Anthropic key.' });
return;
}
const contextBundle = await buildContextBundle();
const systemPrompt = `You are AgriAgent, the dedicated AI advisor for this farm operation. You maintain a persistent "farm memory" — a synthesized knowledge base you build and update over time as you learn more about the operation.
IDENTITY AND ROLE
- You are this farm's trusted advisor with deep, specific knowledge of its fields, crops, soils, finances, and operations.
- You think both tactically (today's weather, this week's harvest window) and strategically (long-term soil health, carbon sequestration, USDA program eligibility).
- Your answers are always grounded in the farm's actual data never guess or use generic advice when specific data is available.
MEMORY PROTOCOL
- The FARM CONTEXT section below is pre-loaded with all available data from every source, including your previously stored farm memory.
- The farm memory (if present) is the most important section it is your synthesized understanding of this operation built from prior analysis. Reference it first.
- When you discover something significant (a pattern, risk, or opportunity not already captured), call update_farm_memory to preserve it for future sessions.
- If farm memory is absent or stale (>14 days old), proactively synthesize one after reviewing the available field and file data.
REASONING APPROACH follow this order:
1. GROUND: What does the farm memory and pre-loaded context already tell me?
2. GAPS: What additional data do I need? Use the right tool don't query what's already in context.
3. CONNECT: Link data across sources (e.g. soil type + weather + crop history harvest recommendation).
4. CITE: Always name fields, dates, acreages, and numbers from the actual data.
5. REMEMBER: Did this conversation reveal anything new? If so, update_farm_memory.
TOOL SELECTION GUIDE
- get_field_profiles field locations, soil type, acreage, crop history, harvest records
- get_weather(lat, lon) live 7-day forecast + GDD; always get field coordinates first
- lookup_usda_soil(lat, lon) USDA soil classification and organic matter data
- get_ingested_files uploaded CSVs, Excel files, PDFs with extracted structured data
- get_reading_list saved articles, research, USDA notices
- calculate_gdd(highs, lows) growing degree day accumulation from temperature data
- screenshot_active_tab capture the current browser page as an image you can see
- get_page_content read text from the active tab or a saved reading-list URL
- open_tab(url) + read_tab_content(tab_id) navigate to a URL and parse its content
- export_farm_data generate and download CSV/JSON of farm data
- get_farm_memory retrieve the stored farm knowledge snapshot
- update_farm_memory save new insights about this farm for future sessions
FARM CONTEXT (all data sources pre-loaded memory, field profiles, ingested files, reading list):
${contextBundle}`;
const messages = [{ role: 'user', content: userMessage }];
this.onEvent({ type: 'thinking', data: 'Analysing your question…' });
for (let i = 0; i < MAX_ITERATIONS; i++) {
const body = {
model: MODEL,
max_tokens: 2048,
system: systemPrompt,
tools: TOOL_DEFINITIONS,
messages,
};
let response;
try {
response = await this._callAPI(apiKey, body);
} catch (err) {
this.onEvent({ type: 'error', data: err.message });
return;
}
// Append assistant turn
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason === 'end_turn' || response.stop_reason === 'max_tokens') {
const text = response.content
.filter((b) => b.type === 'text')
.map((b) => b.text)
.join('\n');
this.onEvent({ type: 'answer', data: text });
return;
}
if (response.stop_reason === 'tool_use') {
const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
const toolResults = [];
for (const block of toolUseBlocks) {
this.onEvent({ type: 'tool_call', data: { name: block.name, input: block.input } });
let result;
try {
result = await executeTool(block.name, block.input);
} catch (err) {
result = { error: err.message };
}
this.onEvent({ type: 'tool_result', data: { name: block.name, result } });
// Screenshot tool returns an image — pass it as a vision content block
if (result && result._type === 'image') {
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: [
{
type: 'image',
source: { type: 'base64', media_type: result.media_type, data: result.data },
},
{
type: 'text',
text: `Screenshot of "${result.title}" (${result.url})`,
},
],
});
} else {
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: JSON.stringify(result),
});
}
}
messages.push({ role: 'user', content: toolResults });
continue;
}
// Unexpected stop reason
this.onEvent({ type: 'error', data: `Unexpected stop reason: ${response.stop_reason}` });
return;
}
this.onEvent({ type: 'error', data: 'Agent reached maximum iterations without completing.' });
}
async _callAPI(apiKey, body) {
const res = await fetch(ANTHROPIC_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Anthropic API ${res.status}: ${text}`);
}
return res.json();
}
}

View file

@ -0,0 +1,249 @@
import { AgrifineAgent } from './agent.js';
const TOOL_ICONS = {
get_reading_list: '📖',
get_field_profiles: '🌱',
get_ingested_files: '📄',
get_weather: '🌤️',
lookup_usda_soil: '🏛️',
calculate_gdd: '📊',
screenshot_active_tab: '📸',
get_page_content: '🔍',
export_farm_data: '⬇️',
open_tab: '🌐',
read_tab_content: '📋',
get_farm_memory: '🧠',
update_farm_memory: '💾',
};
const SUGGESTED_PROMPTS = [
'Review all my farm data and build a farm memory summary',
'What are my current field conditions and harvest windows?',
'What risks or opportunities do you see across my operation?',
'Screenshot this page and tell me what agricultural data you see',
'Export my reading list and field profiles to CSV',
];
export function AgRefineModule() {
let messages = [];
let isRunning = false;
return {
id: 'ag-refine',
label: 'AgriAgent',
async render(container) {
container.innerHTML = `
<div class="flex flex-col h-full">
<!-- Header bar -->
<div class="px-4 pt-4 pb-2 flex-shrink-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-base">🤖</span>
<h2 class="text-sm font-bold text-white">AgriAgent</h2>
<span class="text-xs px-2 py-0.5 rounded-full bg-night-600 text-agri-400 font-medium">AI Agent</span>
</div>
<p class="text-xs text-gray-500">Multi-step reasoning over all your farm data</p>
</div>
<!-- Chat history -->
<div id="agent-chat" class="flex-1 overflow-y-auto px-4 py-2 space-y-3"></div>
<!-- Suggested prompts (shown when empty) -->
<div id="agent-suggestions" class="px-4 pb-2 flex-shrink-0">
<p class="text-xs text-gray-500 mb-2">Try asking</p>
<div class="flex flex-col gap-1.5">
${SUGGESTED_PROMPTS.map((p) => `
<button class="suggest-btn text-left text-xs bg-night-700 hover:bg-night-600 text-agri-400 px-3 py-2 rounded-lg border border-night-500 transition">
${p}
</button>`).join('')}
</div>
</div>
<!-- Input bar -->
<div class="flex-shrink-0 border-t border-night-600 px-3 py-3">
<div class="flex gap-2 items-end">
<textarea id="agent-input" rows="2"
placeholder="Ask the agent anything about your farm…"
class="ag-input flex-1 rounded-xl resize-none"></textarea>
<button id="agent-send"
class="flex-shrink-0 bg-agri-600 hover:bg-agri-700 disabled:bg-night-500 text-white rounded-xl px-3 py-2 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
<button id="agent-clear" class="text-xs text-gray-500 hover:text-gray-300 mt-1 transition">Clear conversation</button>
</div>
</div>
`;
this._bindEvents(container);
this._renderMessages(container);
},
_bindEvents(container) {
const input = container.querySelector('#agent-input');
const sendBtn = container.querySelector('#agent-send');
const send = () => {
const text = input.value.trim();
if (!text || isRunning) return;
input.value = '';
this._runAgent(text, container);
};
sendBtn.addEventListener('click', send);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
container.querySelectorAll('.suggest-btn').forEach((btn) => {
btn.addEventListener('click', () => {
input.value = btn.textContent.trim();
send();
});
});
container.querySelector('#agent-clear').addEventListener('click', () => {
messages = [];
isRunning = false;
this._renderMessages(container);
});
},
async _runAgent(userText, container) {
if (isRunning) return;
isRunning = true;
// Hide suggestions
container.querySelector('#agent-suggestions')?.classList.add('hidden');
// Add user message
messages.push({ role: 'user', text: userText });
this._renderMessages(container);
// Thinking placeholder
const thinkingId = `thinking_${Date.now()}`;
messages.push({ role: 'thinking', id: thinkingId, steps: [] });
this._renderMessages(container);
const thinkingMsg = messages[messages.length - 1];
const agent = new AgrifineAgent({
onEvent: ({ type, data }) => {
if (type === 'thinking') {
thinkingMsg.steps.push({ type: 'status', text: data });
} else if (type === 'tool_call') {
thinkingMsg.steps.push({
type: 'tool',
icon: TOOL_ICONS[data.name] ?? '🔧',
name: data.name.replace(/_/g, ' '),
input: JSON.stringify(data.input),
});
} else if (type === 'tool_result') {
const last = thinkingMsg.steps[thinkingMsg.steps.length - 1];
if (last?.type === 'tool') last.done = true;
} else if (type === 'answer') {
// Replace thinking bubble with final answer
const idx = messages.findIndex((m) => m.id === thinkingId);
if (idx >= 0) messages.splice(idx, 1);
messages.push({ role: 'assistant', text: data });
isRunning = false;
} else if (type === 'error') {
const idx = messages.findIndex((m) => m.id === thinkingId);
if (idx >= 0) messages.splice(idx, 1);
messages.push({ role: 'error', text: data });
isRunning = false;
}
this._renderMessages(container);
},
});
try {
await agent.run(userText);
} catch (err) {
const idx = messages.findIndex((m) => m.id === thinkingId);
if (idx >= 0) messages.splice(idx, 1);
messages.push({ role: 'error', text: err.message });
isRunning = false;
this._renderMessages(container);
}
},
_renderMessages(container) {
const chat = container.querySelector('#agent-chat');
if (!chat) return;
if (messages.length === 0) {
chat.innerHTML = '';
container.querySelector('#agent-suggestions')?.classList.remove('hidden');
return;
}
chat.innerHTML = messages.map((msg) => {
if (msg.role === 'user') {
return `
<div class="flex justify-end">
<div class="max-w-[85%] bg-agri-600 text-white text-sm px-3 py-2 rounded-2xl rounded-tr-sm">
${escapeHtml(msg.text)}
</div>
</div>`;
}
if (msg.role === 'thinking') {
const steps = msg.steps ?? [];
return `
<div class="flex flex-col gap-1.5">
${steps.map((step) => {
if (step.type === 'status') {
return `<div class="flex items-center gap-1.5 text-xs text-gray-500">
<span class="spinner flex-shrink-0"></span> ${escapeHtml(step.text)}
</div>`;
}
if (step.type === 'tool') {
return `<div class="flex items-center gap-2 text-xs bg-night-700 border border-night-600 rounded-lg px-3 py-1.5">
<span>${step.icon}</span>
<span class="font-medium text-agri-400">${step.name}</span>
${step.done ? '<span class="ml-auto text-agri-500">✓</span>' : '<span class="spinner ml-auto flex-shrink-0"></span>'}
</div>`;
}
return '';
}).join('')}
${steps.length === 0 ? '<div class="flex items-center gap-1.5 text-xs text-gray-500"><span class="spinner"></span> Starting…</div>' : ''}
</div>`;
}
if (msg.role === 'assistant') {
return `
<div class="flex gap-2 items-start">
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-night-600 flex items-center justify-center text-xs">🤖</div>
<div class="flex-1 bg-night-700 border border-night-600 rounded-2xl rounded-tl-sm px-3 py-2.5 text-sm text-gray-200 leading-relaxed whitespace-pre-wrap">
${escapeHtml(msg.text)}
</div>
</div>`;
}
if (msg.role === 'error') {
return `
<div class="text-xs bg-red-900/20 border border-red-900/40 text-red-400 rounded-xl px-3 py-2">
${escapeHtml(msg.text)}
</div>`;
}
return '';
}).join('');
// Scroll to bottom
chat.scrollTop = chat.scrollHeight;
},
};
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View file

@ -0,0 +1,598 @@
import { getReadingList, getIngestedFiles, getFieldProfiles, getFarmMemory, saveFarmMemory } from '../utils/storage.js';
function csvEscape(val) {
const s = String(val ?? '');
return (s.includes(',') || s.includes('"') || s.includes('\n'))
? `"${s.replace(/"/g, '""')}"` : s;
}
// ── Tool definitions sent to Claude ──────────────────────────────────────────
export const TOOL_DEFINITIONS = [
{
name: 'get_reading_list',
description: 'Retrieve saved web pages from the user\'s reading list. Can filter by tag.',
input_schema: {
type: 'object',
properties: {
tag: {
type: 'string',
description: 'Optional tag to filter by: agriculture, equipment, land, carbon, USDA, dairy, finance, weather',
},
},
required: [],
},
},
{
name: 'get_field_profiles',
description: 'Retrieve all farm field profiles including acreage, soil type, coordinates, crop history, and notes.',
input_schema: {
type: 'object',
properties: {
field_name: {
type: 'string',
description: 'Optional field name to filter by (partial match)',
},
},
required: [],
},
},
{
name: 'get_ingested_files',
description: 'Retrieve all uploaded and parsed farm data files (CSV, Excel, PDF). Returns structured JSON extracted from each file.',
input_schema: {
type: 'object',
properties: {
type: {
type: 'string',
enum: ['CSV', 'Excel', 'PDF'],
description: 'Optional file type filter',
},
},
required: [],
},
},
{
name: 'get_weather',
description: 'Fetch current conditions, 7-day forecast, and calculate Growing Degree Days (GDD) for a field location using Open-Meteo (free, no key required).',
input_schema: {
type: 'object',
properties: {
latitude: {
type: 'number',
description: 'Latitude of the field',
},
longitude: {
type: 'number',
description: 'Longitude of the field',
},
field_name: {
type: 'string',
description: 'Human-readable field name for context',
},
},
required: ['latitude', 'longitude'],
},
},
{
name: 'lookup_usda_soil',
description: 'Look up soil data from the USDA Web Soil Survey API by coordinates. Returns soil series, texture, organic matter, and drainage class.',
input_schema: {
type: 'object',
properties: {
latitude: {
type: 'number',
description: 'Latitude',
},
longitude: {
type: 'number',
description: 'Longitude',
},
},
required: ['latitude', 'longitude'],
},
},
{
name: 'screenshot_active_tab',
description: 'Take a screenshot of the currently active browser tab. Returns an image Claude can visually inspect — use this to see what the user is currently viewing, check a web page layout, verify data on screen, or analyse any visible content.',
input_schema: {
type: 'object',
properties: {
description: {
type: 'string',
description: 'Optional note about why the screenshot is being taken (for context)',
},
},
required: [],
},
},
{
name: 'get_page_content',
description: 'Fetch the full text content of the currently active browser tab, or look up a saved reading-list URL. Use this to read articles, extract data from web pages, or analyse the text of any page the user has open.',
input_schema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'Optional URL to look up in the saved reading list. If omitted, reads the active tab.',
},
},
required: [],
},
},
{
name: 'export_farm_data',
description: 'Generate and download a CSV or JSON export of farm data. Triggers a file download in the user\'s browser. Use when the user asks to export, download, or save their farm data.',
input_schema: {
type: 'object',
properties: {
data_type: {
type: 'string',
enum: ['reading_list', 'field_profiles', 'ingested_files', 'all'],
description: 'Which data set to export',
},
format: {
type: 'string',
enum: ['csv', 'json'],
description: 'File format (csv or json). "all" data_type always uses json.',
},
},
required: ['data_type'],
},
},
{
name: 'get_farm_memory',
description: 'Retrieve the stored farm memory — the AI-synthesized knowledge base for this operation. Returns the most recent summary, key insights, action items, risk flags, and opportunities identified in prior sessions. Call this if the system context did not already include farm memory.',
input_schema: { type: 'object', properties: {}, required: [] },
},
{
name: 'update_farm_memory',
description: 'Save an updated farm memory snapshot. Call this after synthesizing new insights so future sessions benefit from what you learned. Write a comprehensive aiGeneratedSummary covering the whole farm operation — fields, soils, crops, patterns, and strategic outlook.',
input_schema: {
type: 'object',
properties: {
aiGeneratedSummary: {
type: 'string',
description: 'A rich narrative synthesis of the farm operation. Cover: total acreage, each field\'s status, soil conditions, crop history patterns, financial health (if data available), key risks, and strategic opportunities. Write as a briefing you\'d give a new advisor.',
},
farm_name: { type: 'string', description: 'Farm or operation name if known' },
total_acres: { type: 'number', description: 'Total acreage across all fields' },
primary_crops: { type: 'array', items: { type: 'string' }, description: 'Primary crops grown' },
soil_overview: { type: 'string', description: 'Summary of soil conditions across the operation' },
key_insights: { type: 'array', items: { type: 'string' }, description: 'Most important observations — patterns, correlations, or findings about this farm' },
action_items: { type: 'array', items: { type: 'string' }, description: 'Recommended next steps for the operator' },
risk_flags: { type: 'array', items: { type: 'string' }, description: 'Risks, concerns, or issues to monitor' },
opportunities: { type: 'array', items: { type: 'string' }, description: 'Opportunities identified — programs, practices, markets' },
},
required: ['aiGeneratedSummary'],
},
},
{
name: 'open_tab',
description: 'Open a URL in a new browser tab and wait for it to load. Use this to navigate to a relevant website — USDA, weather services, commodity markets, farm news, etc. After opening, call read_tab_content or screenshot_active_tab to extract information.',
input_schema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'The full URL to open (must start with https:// or http://)',
},
reason: {
type: 'string',
description: 'Why you are opening this URL — shown to the user',
},
},
required: ['url'],
},
},
{
name: 'read_tab_content',
description: 'Extract and parse the text content of a browser tab. Call after open_tab to read the page that was just loaded, or omit tab_id to read the currently active tab.',
input_schema: {
type: 'object',
properties: {
tab_id: {
type: 'number',
description: 'Tab ID returned by open_tab. Omit to read the currently active tab.',
},
},
required: [],
},
},
{
name: 'calculate_gdd',
description: 'Calculate Growing Degree Days from temperature data. Uses base temp of 50°F for forage crops.',
input_schema: {
type: 'object',
properties: {
daily_highs: {
type: 'array',
items: { type: 'number' },
description: 'Array of daily high temperatures in Fahrenheit',
},
daily_lows: {
type: 'array',
items: { type: 'number' },
description: 'Array of daily low temperatures in Fahrenheit',
},
base_temp: {
type: 'number',
description: 'Base temperature in °F (default 50 for forage crops)',
},
},
required: ['daily_highs', 'daily_lows'],
},
},
];
// ── Tool implementations ──────────────────────────────────────────────────────
export async function executeTool(name, input) {
switch (name) {
case 'get_reading_list':
return toolGetReadingList(input);
case 'get_field_profiles':
return toolGetFieldProfiles(input);
case 'get_ingested_files':
return toolGetIngestedFiles(input);
case 'get_weather':
return toolGetWeather(input);
case 'lookup_usda_soil':
return toolLookupUSDAsoil(input);
case 'calculate_gdd':
return toolCalculateGDD(input);
case 'screenshot_active_tab':
return toolScreenshotActiveTab(input);
case 'get_page_content':
return toolGetPageContent(input);
case 'export_farm_data':
return toolExportFarmData(input);
case 'get_farm_memory':
return toolGetFarmMemory();
case 'update_farm_memory':
return toolUpdateFarmMemory(input);
case 'open_tab':
return toolOpenTab(input);
case 'read_tab_content':
return toolReadTabContent(input);
default:
return { error: `Unknown tool: ${name}` };
}
}
async function toolGetReadingList({ tag } = {}) {
const list = await getReadingList();
const filtered = tag ? list.filter((i) => i.tags?.includes(tag)) : list;
return {
count: filtered.length,
items: filtered.slice(0, 30).map((i) => ({
title: i.title,
url: i.url,
summary: i.summary,
tags: i.tags,
savedAt: i.savedAt,
})),
};
}
async function toolGetFieldProfiles({ field_name } = {}) {
const profiles = await getFieldProfiles();
const filtered = field_name
? profiles.filter((p) => p.name?.toLowerCase().includes(field_name.toLowerCase()))
: profiles;
return {
count: filtered.length,
profiles: filtered.map((p) => ({
id: p.id,
name: p.name,
acres: p.acres,
soilType: p.soilType,
cluId: p.cluId,
coordinates: p.coordinates,
notes: p.notes,
cropHistory: p.cropHistory,
harvestRecords: p.harvestRecords,
createdAt: p.createdAt,
})),
};
}
async function toolGetIngestedFiles({ type } = {}) {
const files = await getIngestedFiles();
const filtered = type ? files.filter((f) => f.type === type) : files;
return {
count: filtered.length,
files: filtered.map((f) => ({
filename: f.filename,
type: f.type,
uploadedAt: f.uploadedAt,
structuredData: f.structuredData,
})),
};
}
async function toolGetWeather({ latitude, longitude, field_name = 'field' }) {
const url = new URL('https://api.open-meteo.com/v1/forecast');
url.searchParams.set('latitude', latitude);
url.searchParams.set('longitude', longitude);
url.searchParams.set('current', 'temperature_2m,precipitation,wind_speed_10m,weather_code');
url.searchParams.set('daily', 'temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max');
url.searchParams.set('temperature_unit', 'fahrenheit');
url.searchParams.set('wind_speed_unit', 'mph');
url.searchParams.set('precipitation_unit', 'inch');
url.searchParams.set('forecast_days', '7');
url.searchParams.set('timezone', 'auto');
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`Open-Meteo error: ${res.status}`);
const data = await res.json();
const current = data.current;
const daily = data.daily;
// GDD accumulation from forecast
const gddDays = daily.temperature_2m_max.map((hi, i) => {
const lo = daily.temperature_2m_min[i];
return Math.max(0, ((hi + lo) / 2) - 50);
});
const totalGDD = gddDays.reduce((a, b) => a + b, 0);
// Rain alert: >0.5 inch in next 48h
const rainAlert = (daily.precipitation_sum[0] ?? 0) + (daily.precipitation_sum[1] ?? 0) > 0.5;
// Harvest window
const avgRainProb = (daily.precipitation_probability_max.slice(0, 3).reduce((a, b) => a + b, 0) / 3);
const harvestWindow = avgRainProb < 20 ? 'GREEN' : avgRainProb < 50 ? 'YELLOW' : 'RED';
return {
field: field_name,
coordinates: { latitude, longitude },
current: {
temperature_f: current.temperature_2m,
precipitation_in: current.precipitation,
wind_mph: current.wind_speed_10m,
},
forecast_7day: daily.time.map((date, i) => ({
date,
high_f: daily.temperature_2m_max[i],
low_f: daily.temperature_2m_min[i],
precip_in: daily.precipitation_sum[i],
rain_probability_pct: daily.precipitation_probability_max[i],
gdd: gddDays[i].toFixed(1),
})),
gdd_7day_total: totalGDD.toFixed(1),
rain_alert_48h: rainAlert,
harvest_window: harvestWindow,
};
}
async function toolLookupUSDAsoil({ latitude, longitude }) {
// USDA Web Soil Survey SDA REST API
const query = `SELECT mapunit.muname, component.compname, component.comppct_r,
component.taxorder, component.taxsubgrp, chorizon.texture, chorizon.om_r,
chorizon.drainagecl
FROM mapunit
INNER JOIN component ON mapunit.mukey = component.mukey
INNER JOIN chorizon ON component.cokey = chorizon.cokey
WHERE mu_lks.mukey IN (
SELECT * FROM SDA_Get_Mukey_from_intersection_with_WktWgs84(
'point(${longitude} ${latitude})')
)
AND component.majcompflag = 'Yes'
ORDER BY component.comppct_r DESC`;
try {
const res = await fetch('https://sdmdataaccess.sc.egov.usda.gov/TABULAR/post.rest', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `request=query&query=${encodeURIComponent(query)}&format=JSON`,
});
if (!res.ok) throw new Error(`USDA SDA API ${res.status}`);
const data = await res.json();
const rows = data.Table ?? [];
return {
coordinates: { latitude, longitude },
soil_data: rows.slice(0, 5).map((r) => ({
map_unit: r[0],
component: r[1],
percent: r[2],
tax_order: r[3],
subgroup: r[4],
texture: r[5],
organic_matter_pct: r[6],
drainage_class: r[7],
})),
};
} catch (_) {
return {
coordinates: { latitude, longitude },
note: 'USDA SDA API unavailable — soil data requires network access from background worker',
};
}
}
function toolScreenshotActiveTab() {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type: 'CAPTURE_SCREENSHOT' }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (response?.error) {
reject(new Error(response.error));
return;
}
// Strip data URL prefix — agent.js will format this as an image content block
const base64 = response.dataUrl.replace(/^data:image\/\w+;base64,/, '');
resolve({
_type: 'image',
media_type: 'image/jpeg',
data: base64,
url: response.url,
title: response.title,
});
});
});
}
async function toolGetPageContent({ url } = {}) {
// Check reading list cache first if a URL was given
if (url) {
const list = await getReadingList();
const saved = list.find((i) => i.url === url || i.url.startsWith(url));
if (saved) {
return {
url: saved.url,
title: saved.title,
summary: saved.summary,
tags: saved.tags,
source: 'reading_list_cache',
};
}
}
// Fall back to reading the active tab via content script
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type: 'GET_ACTIVE_TAB_CONTENT' }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (response?.error) {
reject(new Error(response.error));
return;
}
resolve(response);
});
});
}
async function toolExportFarmData({ data_type, format = 'csv' }) {
let records;
let filename;
let content;
const date = new Date().toISOString().slice(0, 10);
if (data_type === 'all') {
const [rl, files, profiles] = await Promise.all([getReadingList(), getIngestedFiles(), getFieldProfiles()]);
filename = `agrifine_export_${date}.json`;
content = JSON.stringify({ reading_list: rl, ingested_files: files, field_profiles: profiles }, null, 2);
records = rl.length + files.length + profiles.length;
} else if (data_type === 'reading_list') {
const list = await getReadingList();
records = list.length;
filename = `agrifine_reading_list_${date}.${format}`;
if (format === 'json') {
content = JSON.stringify(list, null, 2);
} else {
const hdrs = ['title', 'url', 'summary', 'tags', 'savedAt'];
const rows = list.map((i) => [i.title, i.url, i.summary ?? '', (i.tags ?? []).join('; '), i.savedAt].map(csvEscape));
content = [hdrs.join(','), ...rows.map((r) => r.join(','))].join('\n');
}
} else if (data_type === 'field_profiles') {
const profiles = await getFieldProfiles();
records = profiles.length;
filename = `agrifine_field_profiles_${date}.${format}`;
if (format === 'json') {
content = JSON.stringify(profiles, null, 2);
} else {
const hdrs = ['name', 'acres', 'soil_type', 'latitude', 'longitude', 'clu_id', 'notes', 'created_at'];
const rows = profiles.map((p) => [
p.name, p.acres ?? '', p.soilType ?? '',
p.coordinates?.lat ?? '', p.coordinates?.lon ?? '',
p.cluId ?? '', p.notes ?? '', p.createdAt,
].map(csvEscape));
content = [hdrs.join(','), ...rows.map((r) => r.join(','))].join('\n');
}
} else if (data_type === 'ingested_files') {
const files = await getIngestedFiles();
records = files.length;
filename = `agrifine_ingested_files_${date}.json`;
content = JSON.stringify(files, null, 2);
} else {
return { error: `Unknown data_type: ${data_type}` };
}
// Trigger download inside the sidebar page
const mimeType = filename.endsWith('.json') ? 'application/json' : 'text/csv';
const blob = new Blob([content], { type: mimeType });
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
setTimeout(() => URL.revokeObjectURL(objectUrl), 2000);
return { exported: true, filename, record_count: records, format: filename.split('.').pop(), data_type };
}
async function toolGetFarmMemory() {
const memory = await getFarmMemory();
if (!memory) {
return {
has_memory: false,
message: 'No farm memory stored yet. Review the field profiles and ingested files, then call update_farm_memory to create a persistent knowledge base for this farm.',
};
}
return { has_memory: true, ...memory };
}
async function toolUpdateFarmMemory(input) {
await saveFarmMemory({
aiGeneratedSummary: input.aiGeneratedSummary,
farm_name: input.farm_name ?? null,
total_acres: input.total_acres ?? null,
primary_crops: input.primary_crops ?? [],
soil_overview: input.soil_overview ?? null,
key_insights: input.key_insights ?? [],
action_items: input.action_items ?? [],
risk_flags: input.risk_flags ?? [],
opportunities: input.opportunities ?? [],
});
return { saved: true, message: 'Farm memory updated. Future sessions will begin with this knowledge.' };
}
function toolOpenTab({ url, reason }) {
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return Promise.resolve({ error: 'URL must start with http:// or https://' });
}
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type: 'OPEN_TAB', payload: { url } }, (response) => {
if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; }
if (response?.error) { reject(new Error(response.error)); return; }
resolve({ ...response, reason: reason ?? null });
});
});
}
function toolReadTabContent({ tab_id } = {}) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type: 'READ_TAB_CONTENT', payload: { tab_id: tab_id ?? null } }, (response) => {
if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; }
if (response?.error) { reject(new Error(response.error)); return; }
resolve(response);
});
});
}
function toolCalculateGDD({ daily_highs, daily_lows, base_temp = 50 }) {
const gdd_per_day = daily_highs.map((hi, i) => {
const lo = daily_lows[i] ?? hi;
const avg = (hi + lo) / 2;
return Math.max(0, avg - base_temp);
});
const total = gdd_per_day.reduce((a, b) => a + b, 0);
return {
base_temp_f: base_temp,
days: gdd_per_day.length,
gdd_per_day: gdd_per_day.map((g) => parseFloat(g.toFixed(1))),
total_gdd: parseFloat(total.toFixed(1)),
interpretation:
total < 200 ? 'Early growth stage' :
total < 500 ? 'Vegetative growth' :
total < 900 ? 'Approaching harvest window' :
'Harvest recommended',
};
}

View file

@ -0,0 +1,154 @@
import { sessionGet, sessionSet, localGet, localSet, KEYS } from '../utils/storage.js';
import { fetchAnthropic } from '../utils/api.js';
import { syncFromAgRefine } from '../utils/agrefine-bridge.js';
// Open the side panel when the action icon is clicked
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(console.error);
// Restore saved API key into session on service worker startup
localGet(KEYS.API_KEY_SAVED).then((saved) => {
if (saved) sessionSet(KEYS.API_KEY, saved).catch(() => {});
}).catch(() => {});
// ── Message router ────────────────────────────────────────────────────────────
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
switch (message.type) {
case 'ANTHROPIC_REQUEST':
handleAnthropicRequest(message.payload).then(sendResponse).catch((err) =>
sendResponse({ error: err.message })
);
return true; // keep channel open for async response
case 'SET_API_KEY': {
const { key, remember } = message.payload;
const ops = [sessionSet(KEYS.API_KEY, key)];
if (remember) {
ops.push(localSet(KEYS.API_KEY_SAVED, key));
} else {
ops.push(localSet(KEYS.API_KEY_SAVED, null));
}
Promise.all(ops)
.then(() => sendResponse({ ok: true }))
.catch((err) => sendResponse({ error: err.message }));
return true;
}
case 'GET_PAGE_CONTENT':
sendResponse({ ok: true });
return false;
case 'CAPTURE_SCREENSHOT':
captureActiveTabScreenshot()
.then(sendResponse)
.catch((err) => sendResponse({ error: err.message }));
return true;
case 'GET_ACTIVE_TAB_CONTENT':
getActiveTabContent()
.then(sendResponse)
.catch((err) => sendResponse({ error: err.message }));
return true;
case 'OPEN_TAB':
openUrlInTab(message.payload.url)
.then(sendResponse)
.catch((err) => sendResponse({ error: err.message }));
return true;
case 'READ_TAB_CONTENT':
readTabContent(message.payload?.tab_id)
.then(sendResponse)
.catch((err) => sendResponse({ error: err.message }));
return true;
case 'AGREFINE_SYNC':
syncFromAgRefine()
.then(sendResponse)
.catch((err) => sendResponse({ ok: false, error: err.message }));
return true;
default:
return false;
}
});
async function handleAnthropicRequest({ system, userMessage, maxTokens }) {
const text = await fetchAnthropic({ system, userMessage, maxTokens });
return { text };
}
async function captureActiveTabScreenshot() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) throw new Error('No active tab found');
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'jpeg', quality: 80 });
return { dataUrl, url: tab.url, title: tab.title };
}
async function getActiveTabContent() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) throw new Error('No active tab found');
try {
const resp = await chrome.tabs.sendMessage(tab.id, { type: 'GET_PAGE_INFO' });
return { url: tab.url, title: tab.title, text: resp?.text ?? '', source: 'active_tab' };
} catch (_) {
return {
url: tab.url,
title: tab.title,
text: '',
source: 'active_tab',
note: 'Content script unavailable on this page (chrome://, extensions, etc.)',
};
}
}
function waitForTabLoad(tabId, timeoutMs = 20000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
chrome.tabs.get(tabId).then(resolve).catch(() => reject(new Error('Tab load timed out')));
}, timeoutMs);
function listener(id, info, tab) {
if (id !== tabId || info.status !== 'complete') return;
chrome.tabs.onUpdated.removeListener(listener);
clearTimeout(timer);
resolve(tab);
}
chrome.tabs.onUpdated.addListener(listener);
});
}
async function openUrlInTab(url) {
const tab = await chrome.tabs.create({ url, active: true });
const loaded = await waitForTabLoad(tab.id);
return { tab_id: loaded.id, url: loaded.url, title: loaded.title, status: 'ready' };
}
async function readTabContent(tabId) {
const targetId = tabId
?? (await chrome.tabs.query({ active: true, currentWindow: true }))[0]?.id;
if (!targetId) throw new Error('No tab found');
const [result] = await chrome.scripting.executeScript({
target: { tabId: targetId },
func: () => {
const selectors = ['article', 'main', '[role="main"]', '.content', '#content'];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) {
const clone = el.cloneNode(true);
clone.querySelectorAll('script,style,nav,header,footer,aside').forEach((n) => n.remove());
return { url: location.href, title: document.title, text: clone.innerText.trim().slice(0, 8000) };
}
}
return { url: location.href, title: document.title, text: document.body?.innerText?.slice(0, 8000) ?? '' };
},
});
return result.result;
}
// Keep service worker alive during active side-panel sessions
chrome.runtime.onConnect.addListener((port) => {
if (port.name === 'keepalive') {
port.onDisconnect.addListener(() => {});
}
});

View file

@ -0,0 +1,25 @@
// Content script — minimal surface. Relays page metadata to background on request.
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'GET_PAGE_INFO') {
sendResponse({
url: window.location.href,
title: document.title,
text: extractMainText(),
});
}
});
function extractMainText() {
const selectors = ['article', 'main', '[role="main"]', '.content', '#content', 'body'];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) {
// Strip scripts and styles, return first 8000 chars
const clone = el.cloneNode(true);
clone.querySelectorAll('script,style,nav,header,footer,aside').forEach((n) => n.remove());
return clone.innerText.trim().slice(0, 8000);
}
}
return document.body?.innerText?.slice(0, 8000) ?? '';
}

View file

@ -0,0 +1,56 @@
// Carbon Estimator — Phase 7 stub
// Full implementation: soil organic matter, Scope 3 emissions, USDA NRCS eFOTG API, PDF export
export function CarbonEstimatorModule() {
return {
id: 'carbon-estimator',
label: 'Carbon',
render(container) {
container.innerHTML = `
<div class="section-heading">Carbon Estimator</div>
<div class="px-4">
<!-- Phase 7 preview card -->
<div class="agri-card border-night-600">
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 rounded-full bg-night-600 flex items-center justify-center text-xl">🌿</div>
<div>
<h3 class="text-sm font-bold text-white">Carbon Estimator</h3>
<span class="coming-soon">Coming in Phase 7</span>
</div>
</div>
<p class="text-xs text-gray-400 leading-relaxed">
The Carbon Estimator will calculate your operation's Scope 3 emissions profile
and estimate carbon sequestration potential per field using USDA emission factors.
</p>
</div>
<!-- Feature preview list -->
<div class="space-y-2 mt-2">
${[
['📊', 'Scope 3 Emissions Profile', 'Based on fuel use, crop type, and animal operations'],
['🌱', 'Sequestration Potential', 'Per-field estimate using soil type and land cover'],
['🏛️', 'USDA Program Matcher', 'Match your practices to EQIP, CSP, and CRP programs'],
['📄', 'Carbon Credit PDF', 'Downloadable eligibility summary for carbon marketplaces'],
['📡', 'Marketplace Handoff', 'Send your credit profile to Nori, Pachama, or others (Phase 8)'],
].map(([icon, title, desc]) => `
<div class="flex items-start gap-3 py-2.5 border-b border-night-600 last:border-0">
<span class="text-base">${icon}</span>
<div>
<p class="text-xs font-semibold text-gray-200">${title}</p>
<p class="text-xs text-gray-500">${desc}</p>
</div>
</div>`).join('')}
</div>
<!-- Notify placeholder -->
<div class="mt-4 bg-night-800 rounded-xl p-3 text-center">
<p class="text-xs text-agri-400 font-medium">Your field profile data is already being collected.</p>
<p class="text-xs text-agri-500 mt-0.5">Carbon estimates will populate automatically when Phase 7 lands.</p>
</div>
</div>
`;
},
};
}

View file

@ -0,0 +1,189 @@
import {
getReadingList, getIngestedFiles, getFieldProfiles, buildContextBundle,
} from '../../utils/storage.js';
import { callAnthropic } from '../../utils/api.js';
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
const CATEGORIES = ['all', 'land', 'equipment', 'harvest', 'finance', 'carbon', 'weather'];
function tagCategory(item) {
const tags = item.tags ?? [];
const data = JSON.stringify(item.structuredData ?? {}).toLowerCase();
if (tags.includes('land') || data.includes('field') || data.includes('acre')) return 'land';
if (tags.includes('equipment') || data.includes('equipment')) return 'equipment';
if (data.includes('harvest') || data.includes('yield')) return 'harvest';
if (tags.includes('finance') || data.includes('financ') || data.includes('expense')) return 'finance';
if (tags.includes('carbon') || data.includes('carbon')) return 'carbon';
if (tags.includes('weather') || data.includes('weather')) return 'weather';
return 'other';
}
export function DashboardModule() {
let activeCategory = 'all';
let keyword = '';
let aiAnswer = '';
let aiLoading = false;
return {
id: 'dashboard',
label: 'Dashboard',
async render(container) {
container.innerHTML = `
<div class="section-heading">Farm Dashboard</div>
<!-- AI Query bar -->
<div class="px-4 mb-3">
<div class="flex gap-2">
<input id="dash-ai-input" type="text" placeholder="Ask anything… e.g. highest yield field?"
class="ag-input flex-1 rounded-xl" />
<button id="dash-ai-btn"
class="bg-agri-600 hover:bg-agri-700 text-white text-sm px-4 py-2 rounded-xl transition flex-shrink-0">
Ask
</button>
</div>
<div id="dash-ai-answer" class="hidden mt-2 bg-night-700 border border-night-600 rounded-xl p-3 text-sm text-gray-200 leading-relaxed"></div>
</div>
<!-- Filters -->
<div class="px-4 mb-3">
<div class="flex gap-1.5 flex-wrap mb-2">
${CATEGORIES.map((c) => `
<button data-cat="${c}" class="cat-btn text-xs px-2.5 py-1 rounded-full border transition
${c === activeCategory ? 'bg-agri-600 text-white border-agri-600' : 'border-night-500 text-gray-400 hover:border-agri-500'}">
${c.charAt(0).toUpperCase() + c.slice(1)}
</button>`).join('')}
</div>
<input id="dash-search" type="text" placeholder="Search by keyword or field name…"
class="ag-input" />
</div>
<!-- Unified list -->
<div id="dash-list" class="px-4 pb-4"></div>
`;
this._bindEvents(container);
await this._renderDashboard(container);
},
_bindEvents(container) {
container.querySelectorAll('.cat-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
activeCategory = btn.dataset.cat;
container.querySelectorAll('.cat-btn').forEach((b) => {
b.className = `cat-btn text-xs px-2.5 py-1 rounded-full border transition border-night-500 text-gray-400 hover:border-agri-500`;
});
btn.className = `cat-btn text-xs px-2.5 py-1 rounded-full border transition bg-agri-600 text-white border-agri-600`;
await this._renderDashboard(container);
});
});
container.querySelector('#dash-search').addEventListener('input', async (e) => {
keyword = e.target.value.toLowerCase();
await this._renderDashboard(container);
});
container.querySelector('#dash-ai-btn').addEventListener('click', () => this._runAIQuery(container));
container.querySelector('#dash-ai-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') this._runAIQuery(container);
});
},
async _runAIQuery(container) {
if (aiLoading) return;
const input = container.querySelector('#dash-ai-input');
const question = input.value.trim();
if (!question) return;
aiLoading = true;
const answerEl = container.querySelector('#dash-ai-answer');
answerEl.classList.remove('hidden');
answerEl.innerHTML = '<span class="spinner"></span> Thinking…';
try {
const contextBundle = await buildContextBundle();
const [readingList, ingestedFiles, fieldProfiles] = await Promise.all([
getReadingList(), getIngestedFiles(), getFieldProfiles(),
]);
const dataContext = [
contextBundle,
'',
'INGESTED FILES:',
ingestedFiles.map((f) => `${f.filename}: ${JSON.stringify(f.structuredData ?? {}).slice(0, 400)}`).join('\n') || '(none)',
].join('\n');
const answer = await callAnthropic({
system: `You are a farm management AI assistant with access to this farm's data:\n\n${dataContext}`,
userMessage: question,
maxTokens: 512,
});
answerEl.innerHTML = `<p class="font-medium text-agri-400 mb-1">Answer</p><span class="whitespace-pre-wrap">${escapeHtml(answer)}</span>`;
} catch (err) {
answerEl.textContent = `Error: ${err.message}`;
} finally {
aiLoading = false;
}
},
async _renderDashboard(container) {
const [readingList, ingestedFiles, fieldProfiles] = await Promise.all([
getReadingList(), getIngestedFiles(), getFieldProfiles(),
]);
const allItems = [
...readingList.map((i) => ({ ...i, _source: 'reading', _category: tagCategory(i) })),
...ingestedFiles.map((f) => ({ ...f, _source: 'file', _category: tagCategory(f) })),
...fieldProfiles.map((p) => ({
...p, _source: 'field', _category: 'land', tags: ['land'],
title: `Field: ${p.name}`, summary: `${p.acres ?? '?'} ac — ${p.soilType ?? 'unknown soil'}`,
})),
];
const filtered = allItems.filter((item) => {
const catMatch = activeCategory === 'all' || item._category === activeCategory;
const kwMatch = !keyword ||
(item.title ?? '').toLowerCase().includes(keyword) ||
(item.summary ?? '').toLowerCase().includes(keyword) ||
(item.filename ?? '').toLowerCase().includes(keyword) ||
(item.name ?? '').toLowerCase().includes(keyword);
return catMatch && kwMatch;
});
const listEl = container.querySelector('#dash-list');
if (filtered.length === 0) {
listEl.innerHTML = `<div class="empty-state"><p>No data matches your filters.</p></div>`;
return;
}
listEl.innerHTML = filtered.map((item) => {
const sourceIcon = { reading: '📖', file: '📄', field: '🌱' }[item._source] ?? '•';
const title = item.title ?? item.filename ?? item.name ?? 'Untitled';
const sub = item.summary ?? item.preview?.slice(0, 120) ?? '';
const date = item.savedAt ?? item.uploadedAt ?? item.createdAt ?? '';
return `
<div class="agri-card">
<div class="flex items-start gap-2">
<span class="text-lg flex-shrink-0">${sourceIcon}</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-white truncate">${escapeHtml(title)}</p>
${sub ? `<p class="text-xs text-gray-400 mt-0.5 leading-relaxed line-clamp-2">${escapeHtml(sub)}</p>` : ''}
<div class="flex items-center gap-2 mt-1.5">
<span class="tag-pill">${escapeHtml(item._category)}</span>
${(item.tags ?? []).filter((t) => t !== item._category).slice(0, 2).map((t) => `<span class="tag-pill">${escapeHtml(t)}</span>`).join('')}
${date ? `<span class="text-xs text-gray-500">${new Date(date).toLocaleDateString()}</span>` : ''}
</div>
</div>
</div>
</div>`;
}).join('');
},
};
}

View file

@ -0,0 +1,264 @@
import { getIngestedFiles, saveIngestedFile, deleteIngestedFile } from '../../utils/storage.js';
import { callAnthropic } from '../../utils/api.js';
const SUPPORTED_TYPES = {
'text/csv': 'CSV',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel',
'application/vnd.ms-excel': 'Excel',
'application/pdf': 'PDF',
};
const DOC_SERVER = 'http://localhost:7432';
async function tryDocServer(file) {
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`${DOC_SERVER}/parse`, { method: 'POST', body: fd });
if (!res.ok) return null;
const { text } = await res.json();
return text ?? null;
} catch (_) {
return null;
}
}
export function DataIngestModule() {
return {
id: 'data-ingest',
label: 'Data Ingest',
async render(container) {
container.innerHTML = `
<div class="section-heading">Data Ingest</div>
<!-- Drop zone -->
<div class="px-4 mb-4">
<div id="drop-zone"
class="border-2 border-dashed border-night-500 rounded-xl p-6 text-center cursor-pointer hover:border-agri-500 hover:bg-night-800 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mx-auto text-agri-400 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p class="text-sm font-medium text-gray-300">Drop CSV, Excel, or PDF here</p>
<p class="text-xs text-gray-500 mt-1">or click to select a file</p>
<input id="file-input" type="file" accept=".csv,.xlsx,.xls,.pdf" class="hidden" />
</div>
<div id="ingest-status" class="text-xs text-center text-gray-500 mt-2 min-h-[1rem]"></div>
</div>
<!-- File list -->
<div id="file-list" class="px-4 pb-4"></div>
`;
this._bindEvents(container);
await this._renderFileList(container);
},
_bindEvents(container) {
const dropZone = container.querySelector('#drop-zone');
const fileInput = container.querySelector('#file-input');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-agri-500', 'bg-night-800');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-agri-500', 'bg-night-800');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('border-agri-500', 'bg-night-800');
const file = e.dataTransfer.files[0];
if (file) this._processFile(file, container);
});
fileInput.addEventListener('change', () => {
if (fileInput.files[0]) this._processFile(fileInput.files[0], container);
});
},
async _processFile(file, container) {
const status = container.querySelector('#ingest-status');
const typeName = SUPPORTED_TYPES[file.type]
?? (file.name.endsWith('.csv') ? 'CSV'
: (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) ? 'Excel'
: null);
if (!typeName) {
status.textContent = 'Unsupported file type.';
return;
}
status.textContent = `Parsing ${typeName}`;
let extractedText = '';
// Try Python doc server first (more robust), fall back to browser-side
extractedText = await tryDocServer(file);
if (extractedText) {
status.textContent = `Parsed via Python server…`;
} else {
try {
if (typeName === 'CSV') {
extractedText = await this._parseCSV(file);
} else if (typeName === 'Excel') {
extractedText = await this._parseExcel(file);
} else if (typeName === 'PDF') {
extractedText = await this._parsePDF(file);
}
} catch (err) {
status.textContent = `Parse error: ${err.message}`;
return;
}
}
status.textContent = 'Extracting structured data with AI…';
let structuredData = null;
try {
const raw = await callAnthropic({
system: 'You are an agricultural data analyst. Extract and return structured JSON from this document. Identify: operation type, field names, dates, quantities, equipment, crop types, financial figures, and any carbon or emissions data. Return only valid JSON.',
userMessage: extractedText.slice(0, 6000),
maxTokens: 1024,
});
structuredData = JSON.parse(raw);
} catch (_) {
structuredData = { raw_preview: extractedText.slice(0, 500), parse_error: 'AI extraction unavailable' };
}
const record = {
id: `file_${Date.now()}`,
filename: file.name,
type: typeName,
uploadedAt: new Date().toISOString(),
structuredData,
preview: Object.entries(structuredData ?? {})
.filter(([k]) => k !== 'raw_preview')
.slice(0, 5)
.map(([k, v]) => `${k}: ${JSON.stringify(v).slice(0, 80)}`)
.join('\n'),
};
await saveIngestedFile(record);
status.textContent = 'File processed!';
setTimeout(() => { status.textContent = ''; }, 2000);
await this._renderFileList(container);
},
_parseCSV(file) {
return new Promise((resolve, reject) => {
// PapaParse is loaded dynamically to keep the background bundle lean
import('papaparse').then(({ default: Papa }) => {
Papa.parse(file, {
complete: (results) => {
const rows = results.data.slice(0, 200);
resolve(rows.map((r) => r.join(',')).join('\n'));
},
error: reject,
});
});
});
},
_parseExcel(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const { read, utils } = await import('xlsx');
const wb = read(e.target.result, { type: 'array' });
const lines = [];
wb.SheetNames.slice(0, 3).forEach((name) => {
const ws = wb.Sheets[name];
lines.push(`Sheet: ${name}`);
lines.push(utils.sheet_to_csv(ws).split('\n').slice(0, 100).join('\n'));
});
resolve(lines.join('\n'));
} catch (err) {
reject(err);
}
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
},
_parsePDF(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const pdfjsLib = await import('pdfjs-dist');
pdfjsLib.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL('pdf.worker.js');
const loadingTask = pdfjsLib.getDocument({
data: new Uint8Array(e.target.result),
useWorkerFetch: false,
isEvalSupported: false,
useSystemFonts: true,
});
const pdf = await loadingTask.promise;
const pages = Math.min(pdf.numPages, 10);
const texts = [];
for (let i = 1; i <= pages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
texts.push(content.items.map((s) => s.str).join(' '));
}
resolve(texts.join('\n'));
} catch (err) {
reject(err);
}
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
},
async _renderFileList(container) {
const files = await getIngestedFiles();
const listEl = container.querySelector('#file-list');
if (files.length === 0) {
listEl.innerHTML = `
<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mb-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p>No files ingested yet.</p>
<p class="mt-1 text-xs">Upload a CSV, Excel, or PDF file above.</p>
</div>`;
return;
}
listEl.innerHTML = files.map((f) => `
<div class="agri-card" data-id="${f.id}">
<div class="flex items-start justify-between gap-2">
<div class="flex-1">
<span class="text-xs font-bold uppercase tracking-wide text-agri-400">${f.type}</span>
<p class="text-sm font-semibold text-white leading-snug mt-0.5">${f.filename}</p>
<p class="text-xs text-gray-500 mt-0.5">${new Date(f.uploadedAt).toLocaleDateString()}</p>
</div>
<button class="file-delete-btn text-night-300 hover:text-red-400 transition flex-shrink-0" data-id="${f.id}" title="Remove">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
${f.preview ? `<pre class="text-xs text-gray-400 mt-2 whitespace-pre-wrap bg-night-800 rounded p-2 overflow-hidden max-h-20">${f.preview}</pre>` : ''}
</div>
`).join('');
listEl.querySelectorAll('.file-delete-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
await deleteIngestedFile(btn.dataset.id);
await this._renderFileList(container);
});
});
},
};
}

View file

@ -0,0 +1,211 @@
import { getFieldProfiles, saveFieldProfile, deleteFieldProfile } from '../../utils/storage.js';
import { getAgRefineUrl, setAgRefineUrl } from '../../utils/agrefine-bridge.js';
export function FieldProfileModule() {
let showForm = false;
let expandedId = null;
return {
id: 'field-profile',
label: 'Field Profiles',
async render(container) {
container.innerHTML = `
<div class="section-heading">Field Profiles</div>
<div class="px-4 mb-3 flex gap-2">
<button id="fp-new-btn"
class="flex-1 flex items-center justify-center gap-2 bg-agri-600 hover:bg-agri-700 text-white text-sm font-medium py-2.5 rounded-xl transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
New Field
</button>
<button id="fp-agrefine-sync-btn" title="Sync fields from AG-Refine"
class="flex items-center justify-center gap-1.5 border border-night-500 text-gray-300 hover:border-agri-500 hover:text-agri-400 text-xs font-medium px-3 py-2.5 rounded-xl transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
AG-Refine
</button>
</div>
<div id="fp-sync-status" class="px-4 text-xs min-h-[1rem] mb-2" style="color:#3d4f66;"></div>
<!-- Create form -->
<div id="fp-form" class="hidden px-4 mb-4 bg-night-700 border border-night-600 rounded-xl mx-4 p-4">
<h3 class="text-sm font-semibold text-white mb-3">New Field</h3>
<div class="space-y-2">
<input id="fp-name" type="text" placeholder="Field name *" class="ag-input" />
<input id="fp-clu" type="text" placeholder="CLU ID (optional)" class="ag-input" />
<div class="flex gap-2">
<input id="fp-acres" type="number" placeholder="Acres" class="ag-input w-1/2" />
<input id="fp-soil" type="text" placeholder="Soil type" class="ag-input w-1/2" />
</div>
<div class="flex gap-2">
<input id="fp-lat" type="number" step="any" placeholder="Latitude" class="ag-input w-1/2" />
<input id="fp-lon" type="number" step="any" placeholder="Longitude" class="ag-input w-1/2" />
</div>
<textarea id="fp-notes" rows="2" placeholder="Notes (AI-queryable)"
class="ag-input resize-none"></textarea>
</div>
<div class="flex gap-2 mt-3">
<button id="fp-save-btn"
class="flex-1 bg-agri-600 hover:bg-agri-700 text-white text-sm font-medium py-2 rounded-lg transition">
Save
</button>
<button id="fp-cancel-btn"
class="flex-1 border border-night-500 text-gray-300 text-sm font-medium py-2 rounded-lg hover:bg-night-700 transition">
Cancel
</button>
</div>
</div>
<!-- Profiles list -->
<div id="fp-list" class="px-4 pb-4"></div>
`;
this._bindEvents(container);
await this._renderList(container);
},
_bindEvents(container) {
container.querySelector('#fp-new-btn').addEventListener('click', () => {
showForm = !showForm;
container.querySelector('#fp-form').classList.toggle('hidden', !showForm);
});
container.querySelector('#fp-agrefine-sync-btn').addEventListener('click', () => this._syncAgRefine(container));
container.querySelector('#fp-cancel-btn').addEventListener('click', () => {
showForm = false;
container.querySelector('#fp-form').classList.add('hidden');
});
container.querySelector('#fp-save-btn').addEventListener('click', async () => {
const name = container.querySelector('#fp-name').value.trim();
if (!name) return;
const profile = {
id: `fp_${Date.now()}`,
name,
cluId: container.querySelector('#fp-clu').value.trim() || null,
acres: parseFloat(container.querySelector('#fp-acres').value) || null,
soilType: container.querySelector('#fp-soil').value.trim() || null,
coordinates: {
lat: parseFloat(container.querySelector('#fp-lat').value) || null,
lon: parseFloat(container.querySelector('#fp-lon').value) || null,
},
notes: container.querySelector('#fp-notes').value.trim() || null,
cropHistory: [], // populated from ingested data in Phase 3
harvestRecords: [], // populated from ingested CSVs in Phase 3
weatherData: null, // Phase 6
carbonPotential: null, // Phase 7
createdAt: new Date().toISOString(),
};
await saveFieldProfile(profile);
showForm = false;
container.querySelector('#fp-form').classList.add('hidden');
await this._renderList(container);
});
},
async _syncAgRefine(container) {
const statusEl = container.querySelector('#fp-sync-status');
statusEl.textContent = 'Connecting to AG-Refine tab…';
statusEl.style.color = '#3d4f66';
const result = await chrome.runtime.sendMessage({ type: 'AGREFINE_SYNC' });
if (!result.ok) {
statusEl.textContent = `${result.error}`;
statusEl.style.color = '#f87171';
setTimeout(() => { statusEl.textContent = ''; }, 5000);
return;
}
const parts = [];
if (result.added) parts.push(`${result.added} added`);
if (result.updated) parts.push(`${result.updated} updated`);
if (result.loadsFound) parts.push(`${result.loadsFound} loads found`);
statusEl.textContent = parts.length ? `✓ Synced: ${parts.join(', ')}` : '✓ No new fields found in AG-Refine';
statusEl.style.color = '#4ade80';
setTimeout(() => { statusEl.textContent = ''; }, 4000);
await this._renderList(container);
},
async _renderList(container) {
const profiles = await getFieldProfiles();
const agRefineUrl = await getAgRefineUrl();
const listEl = container.querySelector('#fp-list');
if (profiles.length === 0) {
listEl.innerHTML = `
<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mb-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p>No field profiles yet.</p>
<p class="mt-1 text-xs">Create a profile for each field in your operation.</p>
</div>`;
return;
}
listEl.innerHTML = profiles.map((p) => `
<div class="agri-card cursor-pointer" data-id="${p.id}">
<div class="flex items-center justify-between">
<div class="flex-1">
<h3 class="text-sm font-bold text-white">${p.name}</h3>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-1">
${p.acres ? `<span class="text-xs text-gray-400">${p.acres} ac</span>` : ''}
${p.soilType ? `<span class="text-xs text-gray-400">${p.soilType}</span>` : ''}
${p.cluId ? `<span class="text-xs text-agri-400">CLU ${p.cluId}</span>` : ''}
</div>
</div>
<div class="flex items-center gap-2">
<button class="fp-delete-btn text-night-300 hover:text-red-400 transition" data-id="${p.id}" title="Delete">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<svg class="fp-chevron h-4 w-4 text-gray-500 transition-transform ${expandedId === p.id ? 'rotate-90' : ''}"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
<!-- Expanded detail -->
<div class="fp-detail ${expandedId === p.id ? '' : 'hidden'} mt-3 pt-3 border-t border-night-600 text-xs text-gray-400 space-y-1">
${p.coordinates?.lat != null && p.coordinates?.lon != null ? `<p>📍 ${p.coordinates.lat.toFixed(4)}, ${p.coordinates.lon.toFixed(4)}</p>` : ''}
${p.notes ? `<p>📝 ${p.notes}</p>` : ''}
${p._source?.includes('ag-refine') ? `<p class="text-agri-400">↗ Synced from AG-Refine</p>` : ''}
${agRefineUrl ? `<a href="${agRefineUrl}" target="_blank" rel="noopener noreferrer" class="text-agri-400 hover:underline">Open in AG-Refine ↗</a>` : ''}
<p class="text-gray-500">Weather data: <span class="coming-soon">Phase 6</span></p>
<p class="text-gray-500">Carbon potential: <span class="coming-soon">Phase 7</span></p>
<p class="text-gray-500">Added ${new Date(p.createdAt).toLocaleDateString()}</p>
</div>
</div>
`).join('');
listEl.querySelectorAll('.agri-card').forEach((card) => {
card.addEventListener('click', async (e) => {
if (e.target.closest('.fp-delete-btn')) return;
const id = card.dataset.id;
expandedId = expandedId === id ? null : id;
await this._renderList(container);
});
});
listEl.querySelectorAll('.fp-delete-btn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await deleteFieldProfile(btn.dataset.id);
if (expandedId === btn.dataset.id) expandedId = null;
await this._renderList(container);
});
});
},
};
}

View file

@ -0,0 +1,167 @@
import { getReadingList, saveReadingItem, deleteReadingItem } from '../../utils/storage.js';
import { callAnthropic, AGRICULTURE_TAGS } from '../../utils/api.js';
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function safeHref(url) {
try {
const u = new URL(url);
return (u.protocol === 'https:' || u.protocol === 'http:') ? escapeHtml(url) : '#';
} catch (_) { return '#'; }
}
export function ReadingListModule() {
let currentTag = 'all';
return {
id: 'reading-list',
label: 'Reading List',
async render(container) {
container.innerHTML = `
<div class="section-heading">Intelligence Sources</div>
<!-- Save current page -->
<div class="px-4 mb-3">
<button id="rl-save-btn"
class="w-full flex items-center justify-center gap-2 bg-agri-600 hover:bg-agri-700 text-white text-sm font-medium py-2.5 rounded-lg transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Save current page
</button>
<div id="rl-save-status" class="text-xs text-center mt-1 min-h-[1rem]" style="color:#3d4f66;"></div>
</div>
<!-- Tag filter -->
<div class="px-4 mb-3 flex gap-1.5 flex-wrap">
<button data-tag="all" class="tag-filter-btn tag-pill bg-agri-600 text-white">All</button>
${AGRICULTURE_TAGS.map((t) => `<button data-tag="${t}" class="tag-filter-btn tag-pill">${t}</button>`).join('')}
</div>
<!-- List -->
<div id="rl-list" class="px-4 pb-4"></div>
`;
this._bindEvents(container);
await this._renderList(container);
},
_bindEvents(container) {
container.querySelector('#rl-save-btn').addEventListener('click', () => this._savePage(container));
container.querySelectorAll('.tag-filter-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
currentTag = btn.dataset.tag;
container.querySelectorAll('.tag-filter-btn').forEach((b) => {
b.classList.remove('bg-agri-600', 'text-white');
b.classList.add('bg-agri-100', 'text-agri-800');
});
btn.classList.add('bg-agri-600', 'text-white');
btn.classList.remove('bg-agri-100', 'text-agri-800');
await this._renderList(container);
});
});
},
async _savePage(container) {
const status = container.querySelector('#rl-save-status');
status.textContent = 'Fetching page info…';
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) {
status.textContent = 'No active tab found.';
return;
}
let pageText = '';
try {
const resp = await chrome.tabs.sendMessage(tab.id, { type: 'GET_PAGE_INFO' });
pageText = resp?.text ?? '';
} catch (_) {
pageText = '';
}
status.textContent = 'Summarising with AI…';
let summary = '';
let tags = [];
try {
const rawResponse = await callAnthropic({
system: 'You are an agricultural research assistant. Given web page text, return a JSON object with two fields: "summary" (2-3 sentence plain English summary focused on agricultural relevance) and "tags" (array of relevant tags from: agriculture, equipment, land, carbon, USDA, dairy, finance, weather). Return only valid JSON.',
userMessage: `Title: ${tab.title}\nURL: ${tab.url}\n\nContent:\n${pageText.slice(0, 4000)}`,
maxTokens: 256,
});
const parsed = JSON.parse(rawResponse);
summary = parsed.summary ?? '';
tags = parsed.tags ?? [];
} catch (_) {
summary = '(AI summary unavailable)';
tags = ['agriculture'];
}
const item = {
id: `rl_${Date.now()}`,
url: tab.url,
title: tab.title,
savedAt: new Date().toISOString(),
summary,
tags,
};
await saveReadingItem(item);
status.textContent = 'Saved!';
setTimeout(() => { status.textContent = ''; }, 2000);
await this._renderList(container);
},
async _renderList(container) {
const list = await getReadingList();
const filtered = currentTag === 'all' ? list : list.filter((i) => i.tags?.includes(currentTag));
const listEl = container.querySelector('#rl-list');
if (filtered.length === 0) {
listEl.innerHTML = `
<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mb-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<p>No saved pages yet.</p>
<p class="mt-1 text-xs">Browse to a page and click "Save current page".</p>
</div>`;
return;
}
listEl.innerHTML = filtered.map((item) => `
<div class="agri-card" data-id="${escapeHtml(item.id)}">
<div class="flex items-start justify-between gap-2">
<a href="${safeHref(item.url)}" target="_blank" rel="noopener noreferrer" class="text-sm font-semibold text-agri-400 hover:underline leading-snug flex-1">${escapeHtml(item.title)}</a>
<button class="rl-delete-btn text-night-300 hover:text-red-400 transition flex-shrink-0" data-id="${escapeHtml(item.id)}" title="Remove">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
${item.summary ? `<p class="text-xs text-gray-400 mt-1.5 leading-relaxed">${escapeHtml(item.summary)}</p>` : ''}
<div class="mt-2">
${(item.tags ?? []).map((t) => `<span class="tag-pill">${escapeHtml(t)}</span>`).join('')}
</div>
<p class="text-xs text-gray-500 mt-2">${new Date(item.savedAt).toLocaleDateString()}</p>
</div>
`).join('');
listEl.querySelectorAll('.rl-delete-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
await deleteReadingItem(btn.dataset.id);
await this._renderList(container);
});
});
},
};
}

View file

@ -0,0 +1,134 @@
import './sidebar.css';
import { ReadingListModule } from '../modules/reading-list/index.js';
import { DataIngestModule } from '../modules/data-ingest/index.js';
import { FieldProfileModule } from '../modules/field-profile/index.js';
import { DashboardModule } from '../modules/dashboard/index.js';
import { CarbonEstimatorModule } from '../modules/carbon-estimator/index.js';
import { AgRefineModule } from '../ag-refine/index.js';
import { sessionSet, sessionGet, localGet, localSet, KEYS } from '../utils/storage.js';
import { getAgRefineUrl, setAgRefineUrl } from '../utils/agrefine-bridge.js';
// ── Module registry ───────────────────────────────────────────────────────────
const MODULES = [
ReadingListModule(),
DataIngestModule(),
FieldProfileModule(),
DashboardModule(),
CarbonEstimatorModule(),
AgRefineModule(),
];
const moduleMap = Object.fromEntries(MODULES.map((m) => [m.id, m]));
let activeModuleId = 'reading-list';
// ── Tab navigation ────────────────────────────────────────────────────────────
function setupTabs() {
document.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => activateTab(btn.dataset.tab));
});
}
async function activateTab(id) {
if (!moduleMap[id]) return;
activeModuleId = id;
document.querySelectorAll('.tab-btn').forEach((btn) => {
const active = btn.dataset.tab === id;
btn.classList.toggle('active-tab', active);
btn.style.color = active ? '' : '#3d4f66';
});
const main = document.getElementById('main-content');
main.innerHTML = '';
await moduleMap[id].render(main);
}
// ── Settings panel ────────────────────────────────────────────────────────────
function setupSettings() {
const btn = document.getElementById('btn-settings');
const panel = document.getElementById('settings-panel');
const saveBtn = document.getElementById('btn-save-key');
const input = document.getElementById('api-key-input');
const status = document.getElementById('api-key-status');
const rememberChk = document.getElementById('api-key-remember');
const forgetBtn = document.getElementById('btn-forget-key');
const agRefineInput = document.getElementById('agrefine-url-input');
const agRefineStatus = document.getElementById('agrefine-url-status');
const agRefineSaveBtn = document.getElementById('btn-save-agrefine-url');
btn.addEventListener('click', async () => {
panel.classList.toggle('hidden');
if (!panel.classList.contains('hidden')) {
const sessionKey = await sessionGet(KEYS.API_KEY);
const savedKey = await localGet(KEYS.API_KEY_SAVED);
if (sessionKey || savedKey) {
input.value = '';
input.placeholder = 'Key set — enter new key to replace';
status.textContent = savedKey
? '✓ Key saved across sessions'
: '✓ Key active this session only';
status.style.color = '#4ade80';
}
if (savedKey) {
rememberChk.checked = true;
forgetBtn.classList.remove('hidden');
}
const agUrl = await getAgRefineUrl();
if (agUrl) agRefineInput.value = agUrl;
}
});
saveBtn.addEventListener('click', async () => {
const key = input.value.trim();
if (!key.startsWith('sk-ant-')) {
status.textContent = 'Key must start with sk-ant-';
status.style.color = '#f87171';
return;
}
const remember = rememberChk.checked;
await chrome.runtime.sendMessage({ type: 'SET_API_KEY', payload: { key, remember } });
input.value = '';
input.placeholder = 'Key set — enter new key to replace';
status.textContent = remember ? '✓ Key saved across sessions' : '✓ Saved for this session';
status.style.color = '#4ade80';
forgetBtn.classList.toggle('hidden', !remember);
});
forgetBtn.addEventListener('click', async () => {
await localSet(KEYS.API_KEY_SAVED, null);
rememberChk.checked = false;
forgetBtn.classList.add('hidden');
status.textContent = 'Saved key removed';
status.style.color = '#3d4f66';
});
agRefineSaveBtn.addEventListener('click', async () => {
const url = agRefineInput.value.trim();
await setAgRefineUrl(url);
agRefineStatus.textContent = url ? '✓ AG-Refine URL saved' : '✓ Cleared';
agRefineStatus.style.color = '#4ade80';
setTimeout(() => {
agRefineStatus.style.color = '#3d4f66';
agRefineStatus.textContent = 'Used to sync fields and outputs from your AG-Refine app.';
}, 2500);
});
}
// ── Keepalive port (prevents service worker from being killed) ────────────────
function keepAlive() {
try {
const port = chrome.runtime.connect({ name: 'keepalive' });
port.onDisconnect.addListener(() => {
setTimeout(keepAlive, 5000);
});
} catch (_) {}
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
setupTabs();
setupSettings();
keepAlive();
await activateTab(activeModuleId);
});

View file

@ -0,0 +1,71 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Base ── */
body {
background-color: #0f1621;
color: #e2e8f0;
}
/* ── Active tab ── */
.tab-btn.active-tab {
@apply text-agri-400;
}
.tab-btn.active-tab svg {
@apply stroke-agri-400;
}
.tab-btn.active-tab .tab-dot {
@apply opacity-100;
}
/* ── Shared card ── */
.agri-card {
@apply bg-night-700 rounded-xl border border-night-600 p-4 mb-3 transition-colors;
}
.agri-card:hover {
@apply border-night-500;
}
/* ── Tag pill ── */
.tag-pill {
@apply inline-block text-xs px-2 py-0.5 rounded-full bg-night-600 text-agri-400 font-medium mr-1 mb-1;
}
/* ── Section heading (OVERVIEW / HARVEST / SETUP style) ── */
.section-heading {
@apply text-[10px] uppercase tracking-widest font-semibold text-night-300 mb-2 px-4 pt-4;
}
/* ── Empty state ── */
.empty-state {
@apply flex flex-col items-center justify-center py-16 text-gray-500 text-sm text-center px-6;
}
/* ── Spinner ── */
.spinner {
@apply inline-block w-4 h-4 border-2 border-night-500 border-t-agri-400 rounded-full animate-spin;
}
/* ── Coming soon badge ── */
.coming-soon {
@apply inline-block text-xs px-2 py-0.5 rounded-full bg-night-600 text-agri-400 font-semibold;
}
/* ── Input baseline ── */
.ag-input {
@apply w-full text-sm bg-night-800 border border-night-500 text-white placeholder-gray-500
rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-agri-500 focus:border-agri-500;
}
/* ── Scrollbar ── */
::-webkit-scrollbar {
width: 3px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #1e2d40;
border-radius: 2px;
}

View file

@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AG Refine</title>
<link rel="stylesheet" href="sidebar.css" />
</head>
<body class="h-screen flex flex-col overflow-hidden font-sans" style="background:#0f1621;color:#e2e8f0;">
<!-- Header — matches AG Refine branding -->
<header class="flex items-center justify-between px-4 py-3 flex-shrink-0" style="background:#131c2b;border-bottom:1px solid #1e2d40;">
<div class="flex items-start gap-2.5">
<div class="leading-none">
<div class="text-lg font-black text-white tracking-tight leading-none">AG</div>
<div class="text-[10px] font-bold text-white tracking-widest leading-none mt-0.5">REFINE</div>
<div class="text-[7px] font-semibold tracking-widest leading-none mt-1" style="color:#3d4f66;">FIELD INTELLIGENCE</div>
</div>
</div>
<button id="btn-settings" class="transition" style="color:#3d4f66;" onmouseenter="this.style.color='#e2e8f0'" onmouseleave="this.style.color='#3d4f66'" title="Settings">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</header>
<!-- Settings panel -->
<div id="settings-panel" class="hidden flex-shrink-0 px-4 py-3" style="background:#131c2b;border-bottom:1px solid #1e2d40;">
<label class="block text-[10px] uppercase tracking-widest font-semibold mb-2" style="color:#3d4f66;">Anthropic API Key</label>
<div class="flex gap-2">
<input id="api-key-input" type="password" placeholder="sk-ant-..."
class="ag-input flex-1" />
<button id="btn-save-key"
class="text-xs bg-agri-600 hover:bg-agri-700 text-white px-3 py-1.5 rounded-lg transition font-medium">Save</button>
</div>
<div class="flex items-center justify-between mt-2">
<label class="flex items-center gap-1.5 cursor-pointer select-none">
<input id="api-key-remember" type="checkbox" class="accent-agri-500 w-3 h-3" />
<span class="text-xs" style="color:#3d4f66;">Remember across sessions</span>
</label>
<button id="btn-forget-key" class="text-xs hover:text-red-400 transition hidden" style="color:#3d4f66;">Forget saved key</button>
</div>
<p id="api-key-status" class="text-xs mt-1" style="color:#3d4f66;"></p>
<label class="block text-[10px] uppercase tracking-widest font-semibold mb-2 mt-3" style="color:#3d4f66;">AG-Refine App URL</label>
<div class="flex gap-2">
<input id="agrefine-url-input" type="url" placeholder="http://localhost:3000"
class="ag-input flex-1" />
<button id="btn-save-agrefine-url"
class="text-xs bg-agri-600 hover:bg-agri-700 text-white px-3 py-1.5 rounded-lg transition font-medium">Save</button>
</div>
<p id="agrefine-url-status" class="text-xs mt-1" style="color:#3d4f66;">Used to sync fields and outputs from your AG-Refine app.</p>
</div>
<!-- Main content -->
<main id="main-content" class="flex-1 overflow-y-auto" style="background:#0f1621;"></main>
<!-- Bottom tab bar -->
<nav class="flex-shrink-0" style="background:#131c2b;border-top:1px solid #1e2d40;">
<div class="flex">
<button data-tab="reading-list"
class="tab-btn flex-1 flex flex-col items-center pt-2 pb-1.5 px-1 text-[9px] font-semibold tracking-wide transition active-tab" style="color:#3d4f66;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<span>INTEL</span>
<div class="tab-dot w-1 h-1 rounded-full bg-agri-400 mt-0.5 opacity-0"></div>
</button>
<button data-tab="data-ingest"
class="tab-btn flex-1 flex flex-col items-center pt-2 pb-1.5 px-1 text-[9px] font-semibold tracking-wide transition" style="color:#3d4f66;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<span>INGEST</span>
<div class="tab-dot w-1 h-1 rounded-full bg-agri-400 mt-0.5 opacity-0"></div>
</button>
<button data-tab="field-profile"
class="tab-btn flex-1 flex flex-col items-center pt-2 pb-1.5 px-1 text-[9px] font-semibold tracking-wide transition" style="color:#3d4f66;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>FIELDS</span>
<div class="tab-dot w-1 h-1 rounded-full bg-agri-400 mt-0.5 opacity-0"></div>
</button>
<button data-tab="dashboard"
class="tab-btn flex-1 flex flex-col items-center pt-2 pb-1.5 px-1 text-[9px] font-semibold tracking-wide transition" style="color:#3d4f66;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span>DATA</span>
<div class="tab-dot w-1 h-1 rounded-full bg-agri-400 mt-0.5 opacity-0"></div>
</button>
<button data-tab="carbon-estimator"
class="tab-btn flex-1 flex flex-col items-center pt-2 pb-1.5 px-1 text-[9px] font-semibold tracking-wide transition" style="color:#3d4f66;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
<span>CARBON</span>
<div class="tab-dot w-1 h-1 rounded-full bg-agri-400 mt-0.5 opacity-0"></div>
</button>
<button data-tab="ag-refine"
class="tab-btn flex-1 flex flex-col items-center pt-2 pb-1.5 px-1 text-[9px] font-semibold tracking-wide transition" style="color:#3d4f66;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span>AGENT</span>
<div class="tab-dot w-1 h-1 rounded-full bg-agri-400 mt-0.5 opacity-0"></div>
</button>
</div>
</nav>
<script src="sidebar.js"></script>
</body>
</html>

View file

@ -0,0 +1,203 @@
/**
* AG-Refine Sister-App Bridge
*
* Detects an open AG-Refine tab, pulls field and output data from its
* localStorage/sessionStorage, and maps it into Agrifine field profiles.
*
* AG-Refine tab detection: any tab whose URL matches a configurable pattern
* (default: localhost:* OR any URL containing "ag-refine" or "agrefine").
* Set the URL in Settings > AG-Refine URL to pin it to a specific origin.
*/
import { getFieldProfiles, saveFieldProfile, localGet, localSet } from './storage.js';
const AGREFINE_KEY = 'agrifine_agrefine_url';
const SYNC_LOG_KEY = 'agrifine_agrefine_sync_log';
export async function getAgRefineUrl() {
return (await localGet(AGREFINE_KEY)) ?? '';
}
export async function setAgRefineUrl(url) {
await localSet(AGREFINE_KEY, url);
}
export async function getSyncLog() {
return (await localGet(SYNC_LOG_KEY)) ?? [];
}
function tabMatchesAgRefine(tab, configuredUrl) {
if (!tab.url) return false;
if (configuredUrl) {
try {
const origin = new URL(configuredUrl).origin;
return tab.url.startsWith(origin);
} catch (_) {}
}
const u = tab.url.toLowerCase();
return (
u.includes('ag-refine') ||
u.includes('agrefine') ||
u.startsWith('http://localhost') ||
u.startsWith('http://127.0.0.1')
);
}
// Injected into the AG-Refine tab — reads all storage and DOM hints
function scrapeAgRefineTab() {
const out = { localStorage: {}, sessionStorage: {}, domHints: {} };
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
try { out.localStorage[k] = JSON.parse(localStorage.getItem(k)); }
catch (_) { out.localStorage[k] = localStorage.getItem(k); }
}
for (let i = 0; i < sessionStorage.length; i++) {
const k = sessionStorage.key(i);
try { out.sessionStorage[k] = JSON.parse(sessionStorage.getItem(k)); }
catch (_) { out.sessionStorage[k] = sessionStorage.getItem(k); }
}
// Pull field-name-like text from the DOM as a fallback hint
const fieldEls = document.querySelectorAll('[data-field],[data-name],[data-id]');
fieldEls.forEach((el) => {
const id = el.dataset.field ?? el.dataset.id ?? el.dataset.name;
if (id) out.domHints[id] = (el.textContent ?? '').trim().slice(0, 200);
});
return out;
}
/**
* Map raw AG-Refine storage dump to Agrifine field profile shape.
* Tries common key patterns used by React/Next.js ag apps.
*/
function extractFields(raw) {
const all = { ...raw.localStorage, ...raw.sessionStorage };
const candidates = [];
for (const [key, val] of Object.entries(all)) {
const k = key.toLowerCase();
if (!k.includes('field') && !k.includes('load') && !k.includes('farm') && !k.includes('plot')) continue;
const arr = Array.isArray(val) ? val : (val && typeof val === 'object' ? [val] : null);
if (!arr) continue;
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
const name = item.name ?? item.fieldName ?? item.field_name ?? item.title ?? item.label ?? null;
if (!name) continue;
candidates.push({
id: `agr_${item.id ?? item.fieldId ?? Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
name: String(name),
cluId: item.cluId ?? item.clu_id ?? item.clu ?? null,
acres: parseFloat(item.acres ?? item.area ?? item.size ?? item.acreage) || null,
soilType: item.soilType ?? item.soil_type ?? item.soil ?? null,
coordinates: {
lat: parseFloat(item.lat ?? item.latitude ?? item.coordinates?.lat) || null,
lon: parseFloat(item.lon ?? item.lng ?? item.longitude ?? item.coordinates?.lon ?? item.coordinates?.lng) || null,
},
notes: item.notes ?? item.description ?? item.comments ?? null,
cropHistory: Array.isArray(item.cropHistory ?? item.crop_history) ? (item.cropHistory ?? item.crop_history) : [],
harvestRecords: Array.isArray(item.harvests ?? item.harvestRecords) ? (item.harvests ?? item.harvestRecords) : [],
carbonPotential: item.carbonPotential ?? null,
weatherData: null,
createdAt: item.createdAt ?? item.created_at ?? new Date().toISOString(),
_source: 'ag-refine',
});
}
}
return candidates;
}
/**
* Loads also come over map to ingested file records for the dashboard.
*/
function extractLoads(raw) {
const all = { ...raw.localStorage, ...raw.sessionStorage };
const loads = [];
for (const [key, val] of Object.entries(all)) {
const k = key.toLowerCase();
if (!k.includes('load') && !k.includes('scale') && !k.includes('ticket') && !k.includes('delivery')) continue;
const arr = Array.isArray(val) ? val : null;
if (!arr) continue;
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
loads.push(item);
}
}
return loads;
}
export async function syncFromAgRefine() {
const configuredUrl = await getAgRefineUrl();
const allTabs = await chrome.tabs.query({});
const agRefineTabs = allTabs.filter((t) => tabMatchesAgRefine(t, configuredUrl));
if (agRefineTabs.length === 0) {
return { ok: false, error: 'No AG-Refine tab found. Open AG-Refine in a browser tab first.' };
}
const tab = agRefineTabs[0];
let raw;
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: scrapeAgRefineTab,
});
raw = result.result;
} catch (err) {
return { ok: false, error: `Cannot read AG-Refine tab: ${err.message}` };
}
const fields = extractFields(raw);
const loads = extractLoads(raw);
// Merge fields — update existing by name, insert new ones
const existing = await getFieldProfiles();
let added = 0;
let updated = 0;
for (const f of fields) {
const match = existing.find((e) => e.name.toLowerCase() === f.name.toLowerCase());
if (match) {
// Merge: fill in missing data without overwriting user edits
const merged = {
...f,
...match,
coordinates: match.coordinates?.lat != null ? match.coordinates : f.coordinates,
cropHistory: match.cropHistory?.length ? match.cropHistory : f.cropHistory,
notes: match.notes ?? f.notes,
cluId: match.cluId ?? f.cluId,
_source: 'ag-refine-merged',
};
await saveFieldProfile(merged);
updated++;
} else {
await saveFieldProfile(f);
added++;
}
}
const log = {
at: new Date().toISOString(),
tabUrl: tab.url,
fieldsAdded: added,
fieldsUpdated: updated,
loadsFound: loads.length,
rawKeys: Object.keys({ ...raw.localStorage, ...raw.sessionStorage }),
};
const history = await getSyncLog();
history.unshift(log);
await localSet(SYNC_LOG_KEY, history.slice(0, 20));
return { ok: true, added, updated, loadsFound: loads.length, loads, tabUrl: tab.url };
}

View file

@ -0,0 +1,67 @@
import { sessionGet, KEYS } from './storage.js';
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
const MODEL = 'claude-sonnet-4-6';
/**
* Send a message to the Anthropic API via the background service worker.
* Content scripts and sidebar cannot call external APIs directly due to CSP,
* so all API calls are proxied through the background worker.
*/
export async function callAnthropic({ system, userMessage, maxTokens = 1024 }) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(
{ type: 'ANTHROPIC_REQUEST', payload: { system, userMessage, maxTokens } },
(response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (response?.error) {
reject(new Error(response.error));
return;
}
resolve(response?.text ?? '');
}
);
});
}
/**
* Direct fetch from background worker keeps API key off content scripts.
*/
export async function fetchAnthropic({ system, userMessage, maxTokens = 1024 }) {
const apiKey = await sessionGet(KEYS.API_KEY);
if (!apiKey) throw new Error('No API key set. Open Agrifine settings to add your key.');
const body = {
model: MODEL,
max_tokens: maxTokens,
system,
messages: [{ role: 'user', content: userMessage }],
};
const res = await fetch(ANTHROPIC_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`Anthropic API error ${res.status}: ${errText}`);
}
const data = await res.json();
return data.content?.[0]?.text ?? '';
}
export const AGRICULTURE_TAGS = [
'agriculture', 'equipment', 'land', 'carbon',
'USDA', 'dairy', 'finance', 'weather',
];

View file

@ -0,0 +1,238 @@
/**
* Agrifine storage schema
*
* chrome.storage.local keys:
* agrifine_reading_list Array<ReadingItem>
* agrifine_ingested_files Array<IngestedFile>
* agrifine_field_profiles Array<FieldProfile>
* agrifine_settings Settings
* agrifine_farm_memory FarmMemory (AI-synthesized knowledge base)
*
* chrome.storage.session keys:
* agrifine_api_key string (never persisted to local)
*
* FarmMemory shape:
* {
* lastUpdated: ISO string,
* aiGeneratedSummary: string, // Claude's narrative synthesis of the whole farm
* farm_name: string | null,
* total_acres: number | null,
* primary_crops: string[],
* soil_overview: string | null,
* key_insights: string[], // important observations
* action_items: string[], // recommended next steps
* risk_flags: string[], // risks or concerns to watch
* opportunities: string[], // identified opportunities
* }
*/
export const KEYS = {
READING_LIST: 'agrifine_reading_list',
INGESTED_FILES: 'agrifine_ingested_files',
FIELD_PROFILES: 'agrifine_field_profiles',
SETTINGS: 'agrifine_settings',
FARM_MEMORY: 'agrifine_farm_memory',
API_KEY: 'agrifine_api_key', // session storage (always)
API_KEY_SAVED: 'agrifine_api_key_saved', // local storage (when user opts to remember)
};
// ── Generic helpers ──────────────────────────────────────────────────────────
export async function localGet(key) {
return new Promise((resolve, reject) => {
chrome.storage.local.get(key, (result) => {
if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; }
resolve(result[key] ?? null);
});
});
}
export async function localSet(key, value) {
return new Promise((resolve, reject) => {
chrome.storage.local.set({ [key]: value }, () => {
if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; }
resolve();
});
});
}
export async function sessionGet(key) {
return new Promise((resolve, reject) => {
chrome.storage.session.get(key, (result) => {
if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; }
resolve(result[key] ?? null);
});
});
}
export async function sessionSet(key, value) {
return new Promise((resolve, reject) => {
chrome.storage.session.set({ [key]: value }, () => {
if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; }
resolve();
});
});
}
// ── Reading List ─────────────────────────────────────────────────────────────
export async function getReadingList() {
return (await localGet(KEYS.READING_LIST)) ?? [];
}
export async function saveReadingItem(item) {
const list = await getReadingList();
list.unshift(item);
await localSet(KEYS.READING_LIST, list);
}
export async function deleteReadingItem(id) {
const list = await getReadingList();
await localSet(KEYS.READING_LIST, list.filter((i) => i.id !== id));
}
// ── Ingested Files ───────────────────────────────────────────────────────────
export async function getIngestedFiles() {
return (await localGet(KEYS.INGESTED_FILES)) ?? [];
}
export async function saveIngestedFile(file) {
const files = await getIngestedFiles();
files.unshift(file);
await localSet(KEYS.INGESTED_FILES, files);
}
export async function deleteIngestedFile(id) {
const files = await getIngestedFiles();
await localSet(KEYS.INGESTED_FILES, files.filter((f) => f.id !== id));
}
// ── Field Profiles ───────────────────────────────────────────────────────────
export async function getFieldProfiles() {
return (await localGet(KEYS.FIELD_PROFILES)) ?? [];
}
export async function saveFieldProfile(profile) {
const profiles = await getFieldProfiles();
const idx = profiles.findIndex((p) => p.id === profile.id);
if (idx >= 0) {
profiles[idx] = profile;
} else {
profiles.unshift(profile);
}
await localSet(KEYS.FIELD_PROFILES, profiles);
}
export async function deleteFieldProfile(id) {
const profiles = await getFieldProfiles();
await localSet(KEYS.FIELD_PROFILES, profiles.filter((p) => p.id !== id));
}
// ── Farm Memory ──────────────────────────────────────────────────────────────
export async function getFarmMemory() {
return (await localGet(KEYS.FARM_MEMORY)) ?? null;
}
export async function saveFarmMemory(memory) {
await localSet(KEYS.FARM_MEMORY, { ...memory, lastUpdated: new Date().toISOString() });
}
// ── Settings ─────────────────────────────────────────────────────────────────
export async function getSettings() {
return (await localGet(KEYS.SETTINGS)) ?? { theme: 'light', defaultState: 'all' };
}
export async function saveSettings(patch) {
const current = await getSettings();
await localSet(KEYS.SETTINGS, { ...current, ...patch });
}
// ── Context bundle (used as AI system context) ───────────────────────────────
export async function buildContextBundle() {
const [list, profiles, files, memory] = await Promise.all([
getReadingList(),
getFieldProfiles(),
getIngestedFiles(),
getFarmMemory(),
]);
// ── 1. Farm memory (AI-synthesized knowledge — most important, goes first) ──
let memorySection;
if (memory) {
const lines = [
`FARM MEMORY (last updated ${memory.lastUpdated?.slice(0, 10) ?? 'unknown'}):`,
memory.aiGeneratedSummary ?? '',
];
if (memory.primary_crops?.length) lines.push(`Primary crops: ${memory.primary_crops.join(', ')}`);
if (memory.total_acres != null) lines.push(`Total acreage: ${memory.total_acres} ac`);
if (memory.key_insights?.length) {
lines.push('Key insights:');
memory.key_insights.forEach((s) => lines.push(`${s}`));
}
if (memory.action_items?.length) {
lines.push('Action items:');
memory.action_items.forEach((s) => lines.push(`${s}`));
}
if (memory.risk_flags?.length) {
lines.push('Risk flags:');
memory.risk_flags.forEach((s) => lines.push(`${s}`));
}
memorySection = lines.filter(Boolean).join('\n');
} else {
memorySection = 'FARM MEMORY: (none yet — after reviewing field data, call update_farm_memory to build a persistent knowledge base)';
}
// ── 2. Field profiles with crop history and harvest records ──────────────────
const fieldLines = profiles.length === 0 ? ['(none)'] : profiles.map((p) => {
const coords = p.coordinates?.lat != null && p.coordinates?.lon != null
? `${p.coordinates.lat.toFixed(4)}, ${p.coordinates.lon.toFixed(4)}`
: null;
const history = (p.cropHistory ?? []).slice(0, 4).map((h) => `${h.year}: ${h.crop}`).join(', ');
const harvests = (p.harvestRecords ?? []).slice(0, 3)
.map((h) => `${h.date?.slice(0, 10) ?? '?'}: ${h.yield} ${h.unit ?? ''}`.trim()).join('; ');
const parts = [
`Field "${p.name}" | ${p.acres ?? '?'} ac | ${p.soilType ?? 'unknown soil'}`,
coords ? ` Coords: ${coords}` : null,
p.cluId ? ` CLU: ${p.cluId}` : null,
history ? ` Crop history: ${history}` : null,
harvests ? ` Harvests: ${harvests}` : null,
p.notes ? ` Notes: ${p.notes}` : null,
];
return parts.filter(Boolean).join('\n');
});
// ── 3. Ingested data files ───────────────────────────────────────────────────
const fileLines = files.length === 0 ? ['(none)'] : files.slice(0, 10).map((f) => {
const preview = f.structuredData
? Object.entries(f.structuredData)
.filter(([k]) => k !== 'raw_preview' && k !== 'parse_error')
.slice(0, 5)
.map(([k, v]) => `${k}: ${JSON.stringify(v).slice(0, 120)}`)
.join(' | ')
: f.preview?.slice(0, 200) ?? '(no structured data)';
return `[${f.type}] ${f.filename} (${f.uploadedAt?.slice(0, 10) ?? '?'}): ${preview}`;
});
// ── 4. Reading list (recent saved articles) ──────────────────────────────────
const readingLines = list.length === 0 ? ['(none)'] : list.slice(0, 15).map((item) =>
`[${item.tags?.join(', ') ?? 'general'}] "${item.title}": ${item.summary ?? '(no summary)'}`
);
return [
memorySection,
'',
'── FIELD PROFILES ──',
fieldLines.join('\n\n'),
'',
'── INGESTED DATA FILES ──',
fileLines.join('\n'),
'',
'── READING LIST (recent articles) ──',
readingLines.join('\n'),
].join('\n');
}

View file

@ -0,0 +1,45 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,html}'],
theme: {
extend: {
colors: {
agri: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
earth: {
50: '#fdf8f0',
100: '#fbefd8',
200: '#f5d9a8',
300: '#eebb6a',
400: '#e59b38',
500: '#d97f1a',
600: '#c06212',
700: '#9f4b12',
800: '#813c15',
900: '#6a3214',
},
night: {
950: '#080c14',
900: '#0f1621',
800: '#131c2b',
700: '#1a2438',
600: '#1e2d40',
500: '#253047',
400: '#2d3d54',
300: '#3d4f66',
},
},
},
},
plugins: [],
};

View file

@ -0,0 +1,101 @@
"""
Agrifine Document Processing Server
Runs locally at http://localhost:7432 and gives the extension robust
document parsing for CSV, Excel, and PDF files via Python libraries.
Install deps: pip install flask flask-cors pandas openpyxl pypdf2
Run: python tools/doc_server.py
"""
import io
import json
import sys
import traceback
try:
from flask import Flask, request, jsonify
from flask_cors import CORS
import pandas as pd
import PyPDF2
except ImportError as e:
print(f"Missing dependency: {e}")
print("Run: pip install flask flask-cors pandas openpyxl pypdf2")
sys.exit(1)
app = Flask(__name__)
CORS(app, origins=["chrome-extension://*"])
PORT = 7432
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok", "service": "agrifine-doc-server"})
@app.route("/parse", methods=["POST"])
def parse_document():
if "file" not in request.files:
return jsonify({"error": "No file provided"}), 400
f = request.files["file"]
filename = f.filename.lower()
data = f.read()
try:
if filename.endswith(".csv"):
text, preview = _parse_csv(data)
elif filename.endswith((".xlsx", ".xls")):
text, preview = _parse_excel(data, filename)
elif filename.endswith(".pdf"):
text, preview = _parse_pdf(data)
else:
return jsonify({"error": f"Unsupported file type: {filename}"}), 400
return jsonify({"text": text, "preview": preview, "filename": f.filename})
except Exception:
return jsonify({"error": traceback.format_exc()}), 500
def _parse_csv(data: bytes):
df = pd.read_csv(io.BytesIO(data), nrows=500)
text = df.to_csv(index=False)
preview = _df_preview(df)
return text, preview
def _parse_excel(data: bytes, filename: str):
engine = "openpyxl" if filename.endswith(".xlsx") else "xlrd"
xl = pd.ExcelFile(io.BytesIO(data), engine=engine)
parts = []
previews = {}
for sheet in xl.sheet_names[:4]:
df = xl.parse(sheet, nrows=200)
parts.append(f"Sheet: {sheet}\n{df.to_csv(index=False)}")
previews[sheet] = _df_preview(df)
return "\n\n".join(parts), json.dumps(previews)
def _parse_pdf(data: bytes):
reader = PyPDF2.PdfReader(io.BytesIO(data))
pages = min(len(reader.pages), 15)
texts = []
for i in range(pages):
texts.append(reader.pages[i].extract_text() or "")
text = "\n".join(texts)
preview = text[:600].replace("\n", " ").strip()
return text, preview
def _df_preview(df: "pd.DataFrame") -> str:
rows, cols = df.shape
col_names = ", ".join(str(c) for c in df.columns[:10])
sample = df.head(3).to_dict(orient="records")
return f"{rows} rows × {cols} cols | columns: {col_names} | sample: {json.dumps(sample[:2], default=str)[:300]}"
if __name__ == "__main__":
print(f"Agrifine doc server running at http://localhost:{PORT}")
print("The extension will auto-detect this server and use it for document parsing.")
app.run(host="127.0.0.1", port=PORT, debug=False)

View file

@ -0,0 +1,62 @@
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const rootDir = path.resolve(__dirname, '..');
const srcDir = path.join(rootDir, 'src');
const distDir = path.join(rootDir, 'dist');
const publicDir = path.join(rootDir, 'public');
module.exports = {
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
devtool: process.env.NODE_ENV === 'production' ? false : 'cheap-module-source-map',
entry: {
background: path.join(srcDir, 'background', 'index.js'),
content: path.join(srcDir, 'content', 'index.js'),
sidebar: path.join(srcDir, 'sidebar', 'index.js'),
},
output: {
path: distDir,
filename: '[name].js',
clean: true,
publicPath: '/',
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
},
},
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'],
},
],
},
plugins: [
new MiniCssExtractPlugin({ filename: '[name].css' }),
new CopyPlugin({
patterns: [
{ from: path.join(publicDir, 'icons'), to: path.join(distDir, 'icons') },
{ from: path.join(rootDir, 'manifest.json'), to: distDir },
{ from: path.join(srcDir, 'sidebar', 'sidebar.html'), to: path.join(distDir, 'sidebar.html') },
{
from: path.join(rootDir, 'node_modules/pdfjs-dist/build/pdf.worker.min.mjs'),
to: path.join(distDir, 'pdf.worker.js'),
},
],
}),
],
resolve: {
extensions: ['.js'],
alias: {
'@': srcDir,
},
},
};